spice-couchbase 2.0.60 → 2.0.61

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.
@@ -0,0 +1,376 @@
1
+ "use strict";
2
+
3
+ function asyncGeneratorStep(gen, resolve, reject, _next, _throw, key, arg) { try { var info = gen[key](arg); var value = info.value; } catch (error) { reject(error); return; } if (info.done) { resolve(value); } else { Promise.resolve(value).then(_next, _throw); } }
4
+
5
+ function _asyncToGenerator(fn) { return function () { var self = this, args = arguments; return new Promise(function (resolve, reject) { var gen = fn.apply(self, args); function _next(value) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "next", value); } function _throw(err) { asyncGeneratorStep(gen, resolve, reject, _next, _throw, "throw", err); } _next(undefined); }); }; }
6
+
7
+ function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); if (enumerableOnly) symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; }); keys.push.apply(keys, symbols); } return keys; }
8
+
9
+ function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i] != null ? arguments[i] : {}; if (i % 2) { ownKeys(Object(source), true).forEach(function (key) { _defineProperty(target, key, source[key]); }); } else if (Object.getOwnPropertyDescriptors) { Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)); } else { ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } } return target; }
10
+
11
+ function _defineProperty(obj, key, value) { if (key in obj) { Object.defineProperty(obj, key, { value: value, enumerable: true, configurable: true, writable: true }); } else { obj[key] = value; } return obj; }
12
+
13
+ var assert = require("node:assert/strict");
14
+
15
+ var fs = require("node:fs");
16
+
17
+ var path = require("node:path");
18
+
19
+ var vm = require("node:vm");
20
+
21
+ var {
22
+ test
23
+ } = require("node:test");
24
+
25
+ var {
26
+ buildCoveringIndex,
27
+ buildCoveringIndexDefinition
28
+ } = require("./utility");
29
+
30
+ var keyspace = {
31
+ bucket: "saturn",
32
+ scope: "test",
33
+ collection: "users"
34
+ };
35
+ var lookupQuery = "SELECT name, email FROM users WHERE status = 'active'";
36
+
37
+ function existingIndex(index_key) {
38
+ var overrides = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
39
+ return _objectSpread({
40
+ name: "legacy_covering_index",
41
+ using: "gsi",
42
+ state: "online",
43
+ index_key
44
+ }, overrides);
45
+ }
46
+
47
+ function executionScope() {
48
+ var indexes = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : [];
49
+ var {
50
+ inventoryError,
51
+ inventoryResponse,
52
+ plan
53
+ } = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
54
+ var calls = [];
55
+ return {
56
+ calls,
57
+
58
+ query(statement, options) {
59
+ return _asyncToGenerator(function* () {
60
+ calls.push({
61
+ statement,
62
+ options
63
+ });
64
+
65
+ if (statement.startsWith("EXPLAIN ")) {
66
+ return {
67
+ rows: [plan || {
68
+ "#operator": "Fetch"
69
+ }]
70
+ };
71
+ }
72
+
73
+ if (statement.includes("FROM system:indexes")) {
74
+ if (inventoryError) throw inventoryError;
75
+ return inventoryResponse === undefined ? {
76
+ rows: indexes
77
+ } : inventoryResponse;
78
+ }
79
+
80
+ return {
81
+ rows: []
82
+ };
83
+ })();
84
+ }
85
+
86
+ };
87
+ }
88
+
89
+ function createStatements(scope) {
90
+ return scope.calls.filter((_ref) => {
91
+ var {
92
+ statement
93
+ } = _ref;
94
+ return statement.startsWith("CREATE INDEX ");
95
+ });
96
+ } // Isolate optimizer state and time without connecting to Couchbase or sleeping.
97
+
98
+
99
+ function createDatabase(durationMs) {
100
+ var env = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
101
+ var tick = BigInt(0);
102
+ var module = {
103
+ exports: {}
104
+ };
105
+ vm.runInNewContext(fs.readFileSync(path.join(__dirname, "index.js"), "utf8"), {
106
+ require,
107
+ module,
108
+ console,
109
+ process: {
110
+ env,
111
+ hrtime: {
112
+ bigint: () => {
113
+ var current = tick;
114
+ tick += BigInt(durationMs) * BigInt(1000000);
115
+ return current;
116
+ }
117
+ }
118
+ }
119
+ });
120
+ var db = Object.create(module.exports.prototype);
121
+ db.bucket = "saturn";
122
+ db.scope_name = "test";
123
+ db.collection_name = "users";
124
+ db.cluster = {
125
+ query: function () {
126
+ var _query = _asyncToGenerator(function* () {
127
+ return {
128
+ rows: []
129
+ };
130
+ });
131
+
132
+ function query() {
133
+ return _query.apply(this, arguments);
134
+ }
135
+
136
+ return query;
137
+ }()
138
+ };
139
+ return db;
140
+ }
141
+
142
+ test("auto-optimization only starts above the default 3-second threshold",
143
+ /*#__PURE__*/
144
+ _asyncToGenerator(function* () {
145
+ for (var duration of [1001, 2999, 3000, 3001]) {
146
+ var db = createDatabase(duration);
147
+ var optimizations = 0;
148
+ db.optimize =
149
+ /*#__PURE__*/
150
+ _asyncToGenerator(function* () {
151
+ optimizations += 1;
152
+ });
153
+ yield db.query("SELECT name FROM users WHERE status = 'active'");
154
+ assert.equal(optimizations, duration > 3000 ? 1 : 0, "".concat(duration, "ms"));
155
+ }
156
+ }));
157
+ test("the explicit slow-query threshold override remains supported",
158
+ /*#__PURE__*/
159
+ _asyncToGenerator(function* () {
160
+ var db = createDatabase(5001, {
161
+ SPICE_COUCHBASE_SLOW_QUERY_MS: "5000"
162
+ });
163
+ var optimizations = 0;
164
+ db.optimize =
165
+ /*#__PURE__*/
166
+ _asyncToGenerator(function* () {
167
+ optimizations += 1;
168
+ });
169
+ yield db.query("SELECT name FROM users WHERE status = 'active'");
170
+ assert.equal(optimizations, 1);
171
+ }));
172
+ test("Fetch plans for full-document projections never create indexes",
173
+ /*#__PURE__*/
174
+ _asyncToGenerator(function* () {
175
+ var _loop = function* _loop(projection) {
176
+ var db = createDatabase(0);
177
+ var statements = [];
178
+ var query = "SELECT ".concat(projection, " FROM users WHERE status = 'active' ORDER BY created_at DESC");
179
+ yield db.optimize(query, {
180
+ query: function () {
181
+ var _query2 = _asyncToGenerator(function* (statement) {
182
+ statements.push(statement);
183
+ return {
184
+ rows: [{
185
+ "#operator": "Fetch"
186
+ }]
187
+ };
188
+ });
189
+
190
+ function query(_x) {
191
+ return _query2.apply(this, arguments);
192
+ }
193
+
194
+ return query;
195
+ }()
196
+ });
197
+ assert.deepEqual(statements, ["EXPLAIN ".concat(query)]);
198
+ };
199
+
200
+ for (var projection of ["*", "users.*", "`users`.*", "(`users`).*, META(`users`).id AS id"]) {
201
+ yield* _loop(projection);
202
+ }
203
+ }));
204
+ test("Fetch plans for explicit fields still create a covering index",
205
+ /*#__PURE__*/
206
+ _asyncToGenerator(function* () {
207
+ var db = createDatabase(0);
208
+ var scope = executionScope();
209
+ yield db.optimize("SELECT name FROM users WHERE status = 'active'", scope);
210
+ assert.equal(scope.calls.length, 3);
211
+ assert.match(scope.calls[1].statement, /FROM system:indexes/);
212
+ assert.equal(JSON.stringify(scope.calls[1].options.parameters), JSON.stringify(keyspace));
213
+ assert.match(scope.calls[2].statement, /^CREATE INDEX `adv_covered_/);
214
+ assert.match(scope.calls[2].statement, /`status`, `name`/);
215
+ }));
216
+ test("reordered selected columns and changed literals generate identical DDL", () => {
217
+ var original = buildCoveringIndex(lookupQuery, keyspace);
218
+ var reordered = buildCoveringIndex("SELECT email, name FROM users WHERE status = 'pending'", keyspace);
219
+ assert.equal(original, reordered);
220
+ });
221
+ test("adding explicit ascending order cannot rename an identical definition", () => {
222
+ var base = "SELECT status, created_at FROM users WHERE status = 'pending'";
223
+ assert.equal(buildCoveringIndex(base, keyspace), buildCoveringIndex("".concat(base, " ORDER BY created_at ASC"), keyspace));
224
+ });
225
+ test("genuinely different access-key order and direction keep distinct definitions", () => {
226
+ var base = "SELECT name, email FROM users WHERE status = 'active'";
227
+ var asc = buildCoveringIndex("".concat(base, " ORDER BY name, email"), keyspace);
228
+ var desc = buildCoveringIndex("".concat(base, " ORDER BY name DESC, email"), keyspace);
229
+ var reordered = buildCoveringIndex("".concat(base, " ORDER BY email, name"), keyspace);
230
+ assert.notEqual(asc, desc);
231
+ assert.notEqual(asc, reordered);
232
+ assert.notEqual(asc.match(/adv_covered_\w+/)[0], reordered.match(/adv_covered_\w+/)[0]);
233
+ });
234
+ test("case-distinct document fields are not collapsed", () => {
235
+ var statement = buildCoveringIndex("SELECT Name, name, Status, status FROM users ORDER BY Name DESC", keyspace);
236
+ assert.match(statement, /`Name` DESC/);
237
+ assert.match(statement, /`name`/);
238
+ assert.match(statement, /`Status`/);
239
+ assert.match(statement, /`status`/);
240
+ assert.doesNotMatch(statement, /`name` DESC/);
241
+ });
242
+
243
+ var _loop2 = function _loop2(state) {
244
+ test("an existing ".concat(state, " index with reordered projection keys prevents creation"),
245
+ /*#__PURE__*/
246
+ _asyncToGenerator(function* () {
247
+ var scope = executionScope([existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`email`"], {
248
+ state
249
+ })]);
250
+ yield createDatabase(0).optimize(lookupQuery, scope);
251
+ assert.equal(scope.calls.length, 2);
252
+ assert.equal(createStatements(scope).length, 0);
253
+ }));
254
+ };
255
+
256
+ for (var state of ["online", "deferred", "pending", "building", "offline"]) {
257
+ _loop2(state);
258
+ }
259
+
260
+ test("a wider existing index covers a projection subset without another index",
261
+ /*#__PURE__*/
262
+ _asyncToGenerator(function* () {
263
+ var scope = executionScope([existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`phone`", "`email` DESC"])]);
264
+ yield createDatabase(0).optimize(lookupQuery, scope);
265
+ assert.equal(createStatements(scope).length, 0);
266
+ }));
267
+ test("catalog key formatting, explicit ASC and attribute order are normalized",
268
+ /*#__PURE__*/
269
+ _asyncToGenerator(function* () {
270
+ for (var leadingKey of ["((`deleted`)) ASC INCLUDE MISSING", "deleted INCLUDE MISSING ASC"]) {
271
+ var scope = executionScope([existingIndex([leadingKey, "(status) ASC", "`name`", "email ASC"])]);
272
+ yield createDatabase(0).optimize(lookupQuery, scope);
273
+ assert.equal(createStatements(scope).length, 0, leadingKey);
274
+ }
275
+ }));
276
+ var incompatibleIndexes = [["missing a projection field", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`"])], ["different access prefix", existingIndex(["`deleted` INCLUDE MISSING", "`name`", "`status`", "`email`"])], ["missing-leading-key exclusion", existingIndex(["`deleted`", "`status`", "`name`", "`email`"])], ["case-distinct field", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`Name`", "`email`"])], ["expression instead of the field", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "LOWER(`name`)", "`email`"])], ["array key", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`email`", "DISTINCT ARRAY v FOR v IN tags END"])], ["partial index", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`email`"], {
277
+ condition: "(`status` = 'pending')"
278
+ })], ["primary index", existingIndex([], {
279
+ is_primary: true
280
+ })]];
281
+
282
+ var _loop3 = function _loop3(reason, index) {
283
+ test("does not mistake ".concat(reason, " for a compatible covering index"),
284
+ /*#__PURE__*/
285
+ _asyncToGenerator(function* () {
286
+ var scope = executionScope([index]);
287
+ yield createDatabase(0).optimize(lookupQuery, scope);
288
+ assert.equal(createStatements(scope).length, 1);
289
+ }));
290
+ };
291
+
292
+ for (var [reason, index] of incompatibleIndexes) {
293
+ _loop3(reason, index);
294
+ }
295
+
296
+ test("existing opposite sort direction does not satisfy the requested sort",
297
+ /*#__PURE__*/
298
+ _asyncToGenerator(function* () {
299
+ var query = "".concat(lookupQuery, " ORDER BY created_at DESC");
300
+ var scope = executionScope([existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`created_at`", "`name`", "`email`"])]);
301
+ yield createDatabase(0).optimize(query, scope);
302
+ assert.equal(createStatements(scope).length, 1);
303
+ assert.match(createStatements(scope)[0].statement, /`created_at` DESC/);
304
+ }));
305
+ test("same generated name is never recreated even if its keys are unavailable",
306
+ /*#__PURE__*/
307
+ _asyncToGenerator(function* () {
308
+ var candidate = buildCoveringIndexDefinition(lookupQuery, keyspace);
309
+ var scope = executionScope([{
310
+ name: candidate.name,
311
+ state: "building"
312
+ }]);
313
+ yield createDatabase(0).optimize(lookupQuery, scope);
314
+ assert.equal(createStatements(scope).length, 0);
315
+ }));
316
+ test("catalog failure or incomplete results prevent creation and trigger cooldown",
317
+ /*#__PURE__*/
318
+ _asyncToGenerator(function* () {
319
+ var _loop4 = function* _loop4(options) {
320
+ var db = createDatabase(3001);
321
+ var scope = executionScope([], options);
322
+ yield db.optimize(lookupQuery, scope);
323
+ assert.equal(createStatements(scope).length, 0);
324
+ var retries = 0;
325
+ db.optimize =
326
+ /*#__PURE__*/
327
+ _asyncToGenerator(function* () {
328
+ retries++;
329
+ });
330
+ yield db.query("SELECT email FROM users WHERE status = 'pending'");
331
+ assert.equal(retries, 0);
332
+ };
333
+
334
+ for (var options of [{
335
+ inventoryError: new Error("query service unavailable")
336
+ }, {
337
+ inventoryResponse: {}
338
+ }, {
339
+ inventoryResponse: {
340
+ rows: [],
341
+ meta: {
342
+ warnings: [{
343
+ message: "partial inventory"
344
+ }]
345
+ }
346
+ }
347
+ }]) {
348
+ yield* _loop4(options);
349
+ }
350
+ }));
351
+ test("already-covered plans need neither an inventory lookup nor creation",
352
+ /*#__PURE__*/
353
+ _asyncToGenerator(function* () {
354
+ var scope = executionScope([], {
355
+ plan: {
356
+ "#operator": "IndexScan3"
357
+ }
358
+ });
359
+ yield createDatabase(0).optimize(lookupQuery, scope);
360
+ assert.equal(scope.calls.length, 1);
361
+ }));
362
+ test("a default collection uses default names in its inventory lookup",
363
+ /*#__PURE__*/
364
+ _asyncToGenerator(function* () {
365
+ var db = createDatabase(0);
366
+ db.scope_name = undefined;
367
+ db.collection_name = undefined;
368
+ var scope = executionScope();
369
+ yield db.optimize("SELECT name FROM saturn WHERE status = 'active'", scope);
370
+ assert.equal(JSON.stringify(scope.calls[1].options.parameters), JSON.stringify({
371
+ bucket: "saturn",
372
+ scope: "_default",
373
+ collection: "_default"
374
+ }));
375
+ assert.match(createStatements(scope)[0].statement, /`saturn`\.`_default`\.`_default`/);
376
+ }));
package/build/index.js CHANGED
@@ -35,13 +35,13 @@ if (!Promise.allSettled) {
35
35
  } // Couchbase lookupIn has a limit of 16 operations per call
36
36
 
37
37
 
38
- var LOOKUP_IN_MAX_SPECS = 16; // Auto-optimisation guard rails. `query()` fires `optimize()` (an EXPLAIN plus
39
- // a possible CREATE INDEX) for any statement slower than SLOW_QUERY_MS. When
38
+ var LOOKUP_IN_MAX_SPECS = 16; // Auto-optimisation guard rails. `query()` fires `optimize()` (an EXPLAIN,
39
+ // index inventory check, and possible CREATE INDEX) above SLOW_QUERY_MS. When
40
40
  // the cluster is degraded every statement is slow, so without limits each
41
41
  // query spawned two more heavy ones and deepened the outage. These caps make it
42
42
  // a bounded, once-per-statement background task that backs off on failure.
43
43
 
44
- var SLOW_QUERY_MS = Number(process.env.SPICE_COUCHBASE_SLOW_QUERY_MS || 1000);
44
+ var SLOW_QUERY_MS = Number(process.env.SPICE_COUCHBASE_SLOW_QUERY_MS || 3000);
45
45
  var AUTO_OPTIMIZE_ENABLED = !["0", "false", "no", "off"].includes(String((_process$env$SPICE_CO = process.env.SPICE_COUCHBASE_AUTO_OPTIMIZE) !== null && _process$env$SPICE_CO !== void 0 ? _process$env$SPICE_CO : "true").trim().toLowerCase());
46
46
  var OPTIMIZE_MAX_INFLIGHT = Number(process.env.SPICE_COUCHBASE_OPTIMIZE_MAX_INFLIGHT || 1);
47
47
  var OPTIMIZE_FAILURE_COOLDOWN_MS = Number(process.env.SPICE_COUCHBASE_OPTIMIZE_COOLDOWN_MS || 5 * 60 * 1000); // Statements already optimised in this process. Bounded so it cannot grow
@@ -904,10 +904,11 @@ module.exports = class Database {
904
904
  optimizeState.inFlight += 1;
905
905
 
906
906
  try {
907
- var _explainResponse$rows;
907
+ var _explainResponse$rows, _indexResponse$meta, _indexResponse$meta$w;
908
908
 
909
909
  var {
910
- buildCoveringIndex
910
+ buildCoveringIndexDefinition,
911
+ indexSatisfiesCoveringDefinition
911
912
  } = require("./utility"); // 1. Run EXPLAIN to get the query plan
912
913
 
913
914
 
@@ -922,15 +923,34 @@ module.exports = class Database {
922
923
  } // 3. Build covering index from the query
923
924
 
924
925
 
925
- var indexStatement = buildCoveringIndex(query, {
926
+ var candidate = buildCoveringIndexDefinition(query, {
926
927
  bucket: _this19.bucket,
927
928
  scope: _this19.scope_name,
928
929
  collection: _this19.collection_name
929
- }); // 4. Execute the index creation if we got a valid statement
930
+ });
931
+ if (!candidate) return; // EXPLAIN only describes the chosen plan. An equivalent or wider index
932
+ // may already exist under an older name, or still be deferred/building.
933
+ // Include both catalog representations of the default collection.
934
+
935
+ var indexResponse = yield exec_scope.query("SELECT RAW i FROM system:indexes AS i\n WHERE i.namespace_id = 'default'\n AND ((i.bucket_id = $bucket AND i.scope_id = $scope AND i.keyspace_id = $collection)\n OR ($scope = '_default' AND $collection = '_default'\n AND i.bucket_id IS MISSING AND i.scope_id IS MISSING AND i.keyspace_id = $bucket))", {
936
+ parameters: {
937
+ bucket: candidate.bucket,
938
+ scope: candidate.scope,
939
+ collection: candidate.collection
940
+ }
941
+ }); // A failed or incomplete inventory is not evidence that no index exists.
930
942
 
931
- if (indexStatement) {
932
- yield exec_scope.query(indexStatement);
943
+ if (!Array.isArray(indexResponse === null || indexResponse === void 0 ? void 0 : indexResponse.rows) || (indexResponse === null || indexResponse === void 0 ? void 0 : (_indexResponse$meta = indexResponse.meta) === null || _indexResponse$meta === void 0 ? void 0 : (_indexResponse$meta$w = _indexResponse$meta.warnings) === null || _indexResponse$meta$w === void 0 ? void 0 : _indexResponse$meta$w.length)) {
944
+ throw new Error("Cannot verify existing indexes before auto-optimization");
933
945
  }
946
+
947
+ if (indexResponse.rows.some(index => index.name === candidate.name || indexSatisfiesCoveringDefinition(index, candidate))) {
948
+ return;
949
+ } // Identical definitions use the same name across processes. Couchbase's
950
+ // per-keyspace name uniqueness also prevents a concurrent duplicate.
951
+
952
+
953
+ yield exec_scope.query(candidate.statement);
934
954
  } catch (e) {
935
955
  // A failure here (timeout, index already exists, cluster busy) almost
936
956
  // always means the cluster is struggling. Stop adding EXPLAIN and
package/build/utility.js CHANGED
@@ -235,7 +235,7 @@ function buildOrderDirectionMap(orderByIndexFields) {
235
235
  direction
236
236
  } of orderByIndexFields) {
237
237
  if (direction === "DESC") {
238
- directionMap.set(name.toLowerCase(), direction);
238
+ directionMap.set(name, direction);
239
239
  }
240
240
  }
241
241
 
@@ -243,14 +243,9 @@ function buildOrderDirectionMap(orderByIndexFields) {
243
243
  }
244
244
 
245
245
  function formatIndexField(field, orderDirectionMap) {
246
- var direction = orderDirectionMap.get(field.toLowerCase());
246
+ var direction = orderDirectionMap.get(field);
247
247
  return "`".concat(field, "`").concat(direction ? " ".concat(direction) : "");
248
248
  }
249
-
250
- function formatIndexKey(field, orderDirectionMap) {
251
- var direction = orderDirectionMap.get(field.toLowerCase());
252
- return "".concat(field).concat(direction ? " ".concat(direction) : "");
253
- }
254
249
  /**
255
250
  * Splits SELECT clause by commas, respecting parentheses
256
251
  * @param {string} clause - The SELECT clause content
@@ -284,21 +279,8 @@ function splitSelectClause(clause) {
284
279
 
285
280
  return parts;
286
281
  }
287
-
288
- function hasQualifiedWildcardProjection(query) {
289
- var selectMatch = query.match(/\bSELECT\b([\s\S]+?)\bFROM\b/i);
290
-
291
- if (!selectMatch) {
292
- return false;
293
- }
294
-
295
- return splitSelectClause(selectMatch[1].trim()).some(part => {
296
- var trimmedPart = part.trim();
297
- return /^\(\s*`?\w+`?\s*\)\s*\.\s*\*$/.test(trimmedPart) || /^`?\w+`?\s*\.\s*\*$/.test(trimmedPart);
298
- });
299
- }
300
282
  /**
301
- * Builds a covering or fallback non-covering index CREATE statement from a query
283
+ * Builds a covering index CREATE statement from a query
302
284
  * @param {string} query - The N1QL query to analyze
303
285
  * @param {Object} options - Configuration options
304
286
  * @param {string} options.bucket - The bucket name
@@ -309,6 +291,14 @@ function hasQualifiedWildcardProjection(query) {
309
291
 
310
292
 
311
293
  function buildCoveringIndex(query, options) {
294
+ var _ref, _buildCoveringIndexDe;
295
+
296
+ return (_ref = (_buildCoveringIndexDe = buildCoveringIndexDefinition(query, options)) === null || _buildCoveringIndexDe === void 0 ? void 0 : _buildCoveringIndexDe.statement) !== null && _ref !== void 0 ? _ref : null;
297
+ } // Keep the access keys separate from projection-only keys so an older, wider
298
+ // index can satisfy the same request without depending on its generated name.
299
+
300
+
301
+ function buildCoveringIndexDefinition(query, options) {
312
302
  var {
313
303
  bucket,
314
304
  scope = "_default",
@@ -321,9 +311,10 @@ function buildCoveringIndex(query, options) {
321
311
 
322
312
 
323
313
  var selectFields = extractSelectFields(query);
324
- var isNonCoveringFallback = selectFields === null && hasQualifiedWildcardProjection(query);
325
314
 
326
- if (selectFields === null && !isNonCoveringFallback) {
315
+ if (selectFields === null) {
316
+ // Full-document projections cannot be covered by the extracted fields.
317
+ // Skip them instead of creating a non-covering fallback index.
327
318
  return null;
328
319
  } // Extract fields from WHERE clause
329
320
 
@@ -336,42 +327,36 @@ function buildCoveringIndex(query, options) {
336
327
  var orderDirectionMap = buildOrderDirectionMap(orderByIndexFields);
337
328
  var orderByFields = orderByIndexFields.map(field => field.name); // Remove 'deleted' from whereFields as it will be added first with INCLUDE MISSING
338
329
 
339
- var filteredWhereFields = whereFields.filter(f => f.toLowerCase() !== "deleted"); // Track fields already added to avoid duplicates
330
+ var filteredWhereFields = whereFields.filter(f => f !== "deleted"); // Track fields already added to avoid duplicates
340
331
 
341
- var addedFields = new Set(filteredWhereFields.map(f => f.toLowerCase())); // Filter GROUP BY fields (remove duplicates from WHERE, remove deleted)
332
+ var addedFields = new Set(filteredWhereFields); // Filter GROUP BY fields (remove duplicates from WHERE, remove deleted)
342
333
 
343
334
  var filteredGroupByFields = groupByFields.filter(f => {
344
- var lower = f.toLowerCase();
345
-
346
- if (addedFields.has(lower) || lower === "deleted") {
335
+ if (addedFields.has(f) || f === "deleted") {
347
336
  return false;
348
337
  }
349
338
 
350
- addedFields.add(lower);
339
+ addedFields.add(f);
351
340
  return true;
352
341
  }); // Filter ORDER BY fields (remove duplicates from WHERE and GROUP BY, remove deleted)
353
342
 
354
343
  var filteredOrderByFields = orderByFields.filter(f => {
355
- var lower = f.toLowerCase();
356
-
357
- if (addedFields.has(lower) || lower === "deleted") {
344
+ if (addedFields.has(f) || f === "deleted") {
358
345
  return false;
359
346
  }
360
347
 
361
- addedFields.add(lower);
348
+ addedFields.add(f);
362
349
  return true;
363
350
  }); // Filter SELECT fields (remove duplicates from WHERE, GROUP BY, and ORDER BY, remove deleted)
364
351
 
365
- var selectFieldsFiltered = (selectFields || []).filter(f => {
366
- var lower = f.toLowerCase();
367
-
368
- if (addedFields.has(lower) || lower === "deleted") {
352
+ var selectFieldsFiltered = selectFields.filter(f => {
353
+ if (addedFields.has(f) || f === "deleted") {
369
354
  return false;
370
355
  }
371
356
 
372
- addedFields.add(lower);
357
+ addedFields.add(f);
373
358
  return true;
374
- }); // All fields for the index: WHERE -> GROUP BY -> ORDER BY -> SELECT
359
+ }).sort(); // Projection order has no effect on coverage. Keep access-key order intact.
375
360
 
376
361
  var allFields = [...filteredWhereFields, ...filteredGroupByFields, ...filteredOrderByFields, ...selectFieldsFiltered];
377
362
 
@@ -381,22 +366,80 @@ function buildCoveringIndex(query, options) {
381
366
  } // Build the field list with deleted INCLUDE MISSING first
382
367
 
383
368
 
384
- var fieldList = ["`deleted` INCLUDE MISSING", ...allFields.map(f => formatIndexField(f, orderDirectionMap))].join(", "); // Generate unique index name based on bucket, scope, collection, and fields
385
- // This ensures multiple queries covered by the same index get the same hash
369
+ var keys = ["`deleted` INCLUDE MISSING", ...allFields.map(f => formatIndexField(f, orderDirectionMap))]; // Hash the actual ordered definition, not query syntax. In particular, an
370
+ // explicit ORDER BY ... ASC must not rename an otherwise identical index.
386
371
 
387
- var indexFieldsKey = allFields.map(field => formatIndexKey(field, orderDirectionMap)).sort().join(",");
388
- var orderFieldsKey = orderByIndexFields.map(field => formatIndexKey(field.name, orderDirectionMap)).join(",");
389
- var indexMode = isNonCoveringFallback ? "noncovered" : "covered";
390
- var indexKey = "".concat(bucket, "|").concat(scope, "|").concat(collection, "|mode:").concat(indexMode, "|fields:").concat(indexFieldsKey, "|order:").concat(orderFieldsKey);
372
+ var indexKey = JSON.stringify([bucket, scope, collection, keys]);
391
373
  var hash = generateHash(indexKey);
392
- var indexName = "adv_".concat(indexMode, "_").concat(hash); // Build the CREATE INDEX statement
374
+ var indexName = "adv_covered_".concat(hash); // Build the CREATE INDEX statement
375
+
376
+ var quote = identifier => "`".concat(identifier.replace(/`/g, "``"), "`");
377
+
378
+ var indexStatement = "CREATE INDEX `".concat(indexName, "` ON `default`:").concat(quote(bucket), ".").concat(quote(scope), ".").concat(quote(collection), "(").concat(keys.join(", "), ")");
379
+ return {
380
+ name: indexName,
381
+ statement: indexStatement,
382
+ keys,
383
+ accessKeyCount: 1 + filteredWhereFields.length + filteredGroupByFields.length + filteredOrderByFields.length,
384
+ bucket,
385
+ scope,
386
+ collection
387
+ };
388
+ } // Only compare simple scalar keys. Do not treat expressions, array indexes,
389
+ // partial indexes, or case-distinct document fields as equivalent.
390
+
391
+
392
+ function normalizeIndexKey(key) {
393
+ if (typeof key !== "string") return null;
394
+ var match = key.trim().match(/^(\(*[\t-\r \xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]*(?:`(?:[\0-_a-\uFFFF]|``)+`|[A-Z_a-z][0-9A-Z_a-z]*)[\t-\r \xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]*\)*)([\s\S]*?)$/);
395
+ if (!match) return null;
396
+ var expression = match[1].trim();
397
+
398
+ while (expression.startsWith("(") && expression.endsWith(")")) {
399
+ expression = expression.slice(1, -1).trim();
400
+ }
401
+
402
+ var identifier = expression.match(/^(?:`((?:[^`]|``)+)`|([A-Za-z_]\w*))$/);
403
+ if (!identifier) return null;
404
+ var attributes = match[2].trim().toUpperCase();
405
+
406
+ if (!/^(?:(?:ASC|DESC)(?:\s+INCLUDE\s+MISSING)?|INCLUDE\s+MISSING(?:\s+(?:ASC|DESC))?)?$/.test(attributes)) {
407
+ return null;
408
+ }
409
+
410
+ return {
411
+ field: identifier[1] !== undefined ? identifier[1].replace(/``/g, "`") : identifier[2],
412
+ direction: /\bDESC\b/.test(attributes) ? "DESC" : "ASC",
413
+ includeMissing: /\bINCLUDE\s+MISSING\b/.test(attributes)
414
+ };
415
+ } // Call with indexes from the candidate's exact bucket/scope/collection. State
416
+ // is deliberately not filtered: a matching deferred/building index is already
417
+ // committed work, even though EXPLAIN cannot use it yet.
418
+
419
+
420
+ function indexSatisfiesCoveringDefinition(index, candidate) {
421
+ if (index.is_primary || index.condition || index.using !== "gsi" || !Array.isArray(index.index_key)) {
422
+ return false;
423
+ }
424
+
425
+ var existingKeys = index.index_key.map(normalizeIndexKey);
426
+ var requiredKeys = candidate.keys.map(normalizeIndexKey);
427
+ if (existingKeys.some(key => !key) || requiredKeys.some(key => !key)) return false;
428
+
429
+ for (var i = 0; i < candidate.accessKeyCount; i++) {
430
+ var existing = existingKeys[i];
431
+ var required = requiredKeys[i];
432
+ if (!existing || existing.field !== required.field || existing.direction !== required.direction || existing.includeMissing !== required.includeMissing) return false;
433
+ }
393
434
 
394
- var indexStatement = "CREATE INDEX `".concat(indexName, "` ON `default`:`").concat(bucket, "`.`").concat(scope, "`.`").concat(collection, "`(").concat(fieldList, ")");
395
- return indexStatement;
435
+ var existingFields = new Set(existingKeys.map(key => key.field));
436
+ return requiredKeys.slice(candidate.accessKeyCount).every(key => existingFields.has(key.field));
396
437
  }
397
438
 
398
439
  module.exports = {
399
440
  buildCoveringIndex,
441
+ buildCoveringIndexDefinition,
442
+ indexSatisfiesCoveringDefinition,
400
443
  extractWhereFields,
401
444
  extractSelectFields,
402
445
  extractGroupByFields,
@@ -504,21 +504,21 @@ test("returns null when missing bucket option", () => {
504
504
  });
505
505
  assert.strictEqual(result, null);
506
506
  });
507
- test("returns null when missing scope option", () => {
507
+ test("uses the default scope when scope option is missing", () => {
508
508
  var query = "SELECT name FROM users WHERE status = 'active'";
509
509
  var result = buildCoveringIndex(query, {
510
510
  bucket: "test",
511
511
  collection: "users"
512
512
  });
513
- assert.strictEqual(result, null);
513
+ assert.ok(result.includes("`test`.`_default`.`users`"));
514
514
  });
515
- test("returns null when missing collection option", () => {
515
+ test("uses the default collection when collection option is missing", () => {
516
516
  var query = "SELECT name FROM users WHERE status = 'active'";
517
517
  var result = buildCoveringIndex(query, {
518
518
  bucket: "test",
519
519
  scope: "scope"
520
520
  });
521
- assert.strictEqual(result, null);
521
+ assert.ok(result.includes("`test`.`scope`.`_default`"));
522
522
  });
523
523
  test("generates unique index name from query hash", () => {
524
524
  var query1 = "SELECT name FROM users WHERE status = 'active'";
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "spice-couchbase",
3
- "version": "2.0.60",
3
+ "version": "2.0.61",
4
4
  "description": "",
5
5
  "main": "build/index.js",
6
6
  "scripts": {
7
7
  "test": "node src/utility.test.js",
8
+ "test:auto-optimize": "node --test src/index.auto-optimize.test.js",
8
9
  "test:query-consistency": "node src/index.query-consistency.test.js",
9
10
  "test:count-query": "node src/index.count-query.test.js"
10
11
  },
@@ -0,0 +1,237 @@
1
+ "use strict";
2
+
3
+ const assert = require("node:assert/strict");
4
+ const fs = require("node:fs");
5
+ const path = require("node:path");
6
+ const vm = require("node:vm");
7
+ const { test } = require("node:test");
8
+ const { buildCoveringIndex, buildCoveringIndexDefinition } = require("./utility");
9
+
10
+ const keyspace = { bucket: "saturn", scope: "test", collection: "users" };
11
+ const lookupQuery = "SELECT name, email FROM users WHERE status = 'active'";
12
+
13
+ function existingIndex(index_key, overrides = {}) {
14
+ return { name: "legacy_covering_index", using: "gsi", state: "online", index_key, ...overrides };
15
+ }
16
+
17
+ function executionScope(indexes = [], { inventoryError, inventoryResponse, plan } = {}) {
18
+ const calls = [];
19
+ return {
20
+ calls,
21
+ async query(statement, options) {
22
+ calls.push({ statement, options });
23
+ if (statement.startsWith("EXPLAIN ")) {
24
+ return { rows: [plan || { "#operator": "Fetch" }] };
25
+ }
26
+ if (statement.includes("FROM system:indexes")) {
27
+ if (inventoryError) throw inventoryError;
28
+ return inventoryResponse === undefined ? { rows: indexes } : inventoryResponse;
29
+ }
30
+ return { rows: [] };
31
+ },
32
+ };
33
+ }
34
+
35
+ function createStatements(scope) {
36
+ return scope.calls.filter(({ statement }) => statement.startsWith("CREATE INDEX "));
37
+ }
38
+
39
+ // Isolate optimizer state and time without connecting to Couchbase or sleeping.
40
+ function createDatabase(durationMs, env = {}) {
41
+ let tick = BigInt(0);
42
+ const module = { exports: {} };
43
+ vm.runInNewContext(fs.readFileSync(path.join(__dirname, "index.js"), "utf8"), {
44
+ require,
45
+ module,
46
+ console,
47
+ process: {
48
+ env,
49
+ hrtime: {
50
+ bigint: () => {
51
+ const current = tick;
52
+ tick += BigInt(durationMs) * BigInt(1000000);
53
+ return current;
54
+ },
55
+ },
56
+ },
57
+ });
58
+ const db = Object.create(module.exports.prototype);
59
+ db.bucket = "saturn";
60
+ db.scope_name = "test";
61
+ db.collection_name = "users";
62
+ db.cluster = { query: async () => ({ rows: [] }) };
63
+ return db;
64
+ }
65
+
66
+ test("auto-optimization only starts above the default 3-second threshold", async () => {
67
+ for (const duration of [1001, 2999, 3000, 3001]) {
68
+ const db = createDatabase(duration);
69
+ let optimizations = 0;
70
+ db.optimize = async () => { optimizations += 1; };
71
+ await db.query("SELECT name FROM users WHERE status = 'active'");
72
+ assert.equal(optimizations, duration > 3000 ? 1 : 0, `${duration}ms`);
73
+ }
74
+ });
75
+
76
+ test("the explicit slow-query threshold override remains supported", async () => {
77
+ const db = createDatabase(5001, { SPICE_COUCHBASE_SLOW_QUERY_MS: "5000" });
78
+ let optimizations = 0;
79
+ db.optimize = async () => { optimizations += 1; };
80
+ await db.query("SELECT name FROM users WHERE status = 'active'");
81
+ assert.equal(optimizations, 1);
82
+ });
83
+
84
+ test("Fetch plans for full-document projections never create indexes", async () => {
85
+ for (const projection of ["*", "users.*", "`users`.*", "(`users`).*, META(`users`).id AS id"]) {
86
+ const db = createDatabase(0);
87
+ const statements = [];
88
+ const query = `SELECT ${projection} FROM users WHERE status = 'active' ORDER BY created_at DESC`;
89
+ await db.optimize(query, {
90
+ query: async (statement) => {
91
+ statements.push(statement);
92
+ return { rows: [{ "#operator": "Fetch" }] };
93
+ },
94
+ });
95
+ assert.deepEqual(statements, [`EXPLAIN ${query}`]);
96
+ }
97
+ });
98
+
99
+ test("Fetch plans for explicit fields still create a covering index", async () => {
100
+ const db = createDatabase(0);
101
+ const scope = executionScope();
102
+ await db.optimize("SELECT name FROM users WHERE status = 'active'", scope);
103
+ assert.equal(scope.calls.length, 3);
104
+ assert.match(scope.calls[1].statement, /FROM system:indexes/);
105
+ assert.equal(JSON.stringify(scope.calls[1].options.parameters), JSON.stringify(keyspace));
106
+ assert.match(scope.calls[2].statement, /^CREATE INDEX `adv_covered_/);
107
+ assert.match(scope.calls[2].statement, /`status`, `name`/);
108
+ });
109
+
110
+ test("reordered selected columns and changed literals generate identical DDL", () => {
111
+ const original = buildCoveringIndex(lookupQuery, keyspace);
112
+ const reordered = buildCoveringIndex("SELECT email, name FROM users WHERE status = 'pending'", keyspace);
113
+ assert.equal(original, reordered);
114
+ });
115
+
116
+ test("adding explicit ascending order cannot rename an identical definition", () => {
117
+ const base = "SELECT status, created_at FROM users WHERE status = 'pending'";
118
+ assert.equal(buildCoveringIndex(base, keyspace), buildCoveringIndex(`${base} ORDER BY created_at ASC`, keyspace));
119
+ });
120
+
121
+ test("genuinely different access-key order and direction keep distinct definitions", () => {
122
+ const base = "SELECT name, email FROM users WHERE status = 'active'";
123
+ const asc = buildCoveringIndex(`${base} ORDER BY name, email`, keyspace);
124
+ const desc = buildCoveringIndex(`${base} ORDER BY name DESC, email`, keyspace);
125
+ const reordered = buildCoveringIndex(`${base} ORDER BY email, name`, keyspace);
126
+ assert.notEqual(asc, desc);
127
+ assert.notEqual(asc, reordered);
128
+ assert.notEqual(asc.match(/adv_covered_\w+/)[0], reordered.match(/adv_covered_\w+/)[0]);
129
+ });
130
+
131
+ test("case-distinct document fields are not collapsed", () => {
132
+ const statement = buildCoveringIndex("SELECT Name, name, Status, status FROM users ORDER BY Name DESC", keyspace);
133
+ assert.match(statement, /`Name` DESC/);
134
+ assert.match(statement, /`name`/);
135
+ assert.match(statement, /`Status`/);
136
+ assert.match(statement, /`status`/);
137
+ assert.doesNotMatch(statement, /`name` DESC/);
138
+ });
139
+
140
+ for (const state of ["online", "deferred", "pending", "building", "offline"]) {
141
+ test(`an existing ${state} index with reordered projection keys prevents creation`, async () => {
142
+ const scope = executionScope([
143
+ existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`email`"], { state }),
144
+ ]);
145
+ await createDatabase(0).optimize(lookupQuery, scope);
146
+ assert.equal(scope.calls.length, 2);
147
+ assert.equal(createStatements(scope).length, 0);
148
+ });
149
+ }
150
+
151
+ test("a wider existing index covers a projection subset without another index", async () => {
152
+ const scope = executionScope([
153
+ existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`phone`", "`email` DESC"]),
154
+ ]);
155
+ await createDatabase(0).optimize(lookupQuery, scope);
156
+ assert.equal(createStatements(scope).length, 0);
157
+ });
158
+
159
+ test("catalog key formatting, explicit ASC and attribute order are normalized", async () => {
160
+ for (const leadingKey of ["((`deleted`)) ASC INCLUDE MISSING", "deleted INCLUDE MISSING ASC"]) {
161
+ const scope = executionScope([
162
+ existingIndex([leadingKey, "(status) ASC", "`name`", "email ASC"]),
163
+ ]);
164
+ await createDatabase(0).optimize(lookupQuery, scope);
165
+ assert.equal(createStatements(scope).length, 0, leadingKey);
166
+ }
167
+ });
168
+
169
+ const incompatibleIndexes = [
170
+ ["missing a projection field", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`"])],
171
+ ["different access prefix", existingIndex(["`deleted` INCLUDE MISSING", "`name`", "`status`", "`email`"])],
172
+ ["missing-leading-key exclusion", existingIndex(["`deleted`", "`status`", "`name`", "`email`"])],
173
+ ["case-distinct field", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`Name`", "`email`"])],
174
+ ["expression instead of the field", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "LOWER(`name`)", "`email`"])],
175
+ ["array key", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`email`", "DISTINCT ARRAY v FOR v IN tags END"])],
176
+ ["partial index", existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`name`", "`email`"], { condition: "(`status` = 'pending')" })],
177
+ ["primary index", existingIndex([], { is_primary: true })],
178
+ ];
179
+ for (const [reason, index] of incompatibleIndexes) {
180
+ test(`does not mistake ${reason} for a compatible covering index`, async () => {
181
+ const scope = executionScope([index]);
182
+ await createDatabase(0).optimize(lookupQuery, scope);
183
+ assert.equal(createStatements(scope).length, 1);
184
+ });
185
+ }
186
+
187
+ test("existing opposite sort direction does not satisfy the requested sort", async () => {
188
+ const query = `${lookupQuery} ORDER BY created_at DESC`;
189
+ const scope = executionScope([
190
+ existingIndex(["`deleted` INCLUDE MISSING", "`status`", "`created_at`", "`name`", "`email`"]),
191
+ ]);
192
+ await createDatabase(0).optimize(query, scope);
193
+ assert.equal(createStatements(scope).length, 1);
194
+ assert.match(createStatements(scope)[0].statement, /`created_at` DESC/);
195
+ });
196
+
197
+ test("same generated name is never recreated even if its keys are unavailable", async () => {
198
+ const candidate = buildCoveringIndexDefinition(lookupQuery, keyspace);
199
+ const scope = executionScope([{ name: candidate.name, state: "building" }]);
200
+ await createDatabase(0).optimize(lookupQuery, scope);
201
+ assert.equal(createStatements(scope).length, 0);
202
+ });
203
+
204
+ test("catalog failure or incomplete results prevent creation and trigger cooldown", async () => {
205
+ for (const options of [
206
+ { inventoryError: new Error("query service unavailable") },
207
+ { inventoryResponse: {} },
208
+ { inventoryResponse: { rows: [], meta: { warnings: [{ message: "partial inventory" }] } } },
209
+ ]) {
210
+ const db = createDatabase(3001);
211
+ const scope = executionScope([], options);
212
+ await db.optimize(lookupQuery, scope);
213
+ assert.equal(createStatements(scope).length, 0);
214
+ let retries = 0;
215
+ db.optimize = async () => { retries++; };
216
+ await db.query("SELECT email FROM users WHERE status = 'pending'");
217
+ assert.equal(retries, 0);
218
+ }
219
+ });
220
+
221
+ test("already-covered plans need neither an inventory lookup nor creation", async () => {
222
+ const scope = executionScope([], { plan: { "#operator": "IndexScan3" } });
223
+ await createDatabase(0).optimize(lookupQuery, scope);
224
+ assert.equal(scope.calls.length, 1);
225
+ });
226
+
227
+ test("a default collection uses default names in its inventory lookup", async () => {
228
+ const db = createDatabase(0);
229
+ db.scope_name = undefined;
230
+ db.collection_name = undefined;
231
+ const scope = executionScope();
232
+ await db.optimize("SELECT name FROM saturn WHERE status = 'active'", scope);
233
+ assert.equal(JSON.stringify(scope.calls[1].options.parameters), JSON.stringify({
234
+ bucket: "saturn", scope: "_default", collection: "_default",
235
+ }));
236
+ assert.match(createStatements(scope)[0].statement, /`saturn`\.`_default`\.`_default`/);
237
+ });
package/src/index.js CHANGED
@@ -25,12 +25,12 @@ if (!Promise.allSettled) {
25
25
  // Couchbase lookupIn has a limit of 16 operations per call
26
26
  const LOOKUP_IN_MAX_SPECS = 16;
27
27
 
28
- // Auto-optimisation guard rails. `query()` fires `optimize()` (an EXPLAIN plus
29
- // a possible CREATE INDEX) for any statement slower than SLOW_QUERY_MS. When
28
+ // Auto-optimisation guard rails. `query()` fires `optimize()` (an EXPLAIN,
29
+ // index inventory check, and possible CREATE INDEX) above SLOW_QUERY_MS. When
30
30
  // the cluster is degraded every statement is slow, so without limits each
31
31
  // query spawned two more heavy ones and deepened the outage. These caps make it
32
32
  // a bounded, once-per-statement background task that backs off on failure.
33
- const SLOW_QUERY_MS = Number(process.env.SPICE_COUCHBASE_SLOW_QUERY_MS || 1000);
33
+ const SLOW_QUERY_MS = Number(process.env.SPICE_COUCHBASE_SLOW_QUERY_MS || 3000);
34
34
  const AUTO_OPTIMIZE_ENABLED = !["0", "false", "no", "off"].includes(
35
35
  String(process.env.SPICE_COUCHBASE_AUTO_OPTIMIZE ?? "true")
36
36
  .trim()
@@ -856,7 +856,10 @@ module.exports = class Database {
856
856
  async optimize(query, exec_scope) {
857
857
  optimizeState.inFlight += 1;
858
858
  try {
859
- const { buildCoveringIndex } = require("./utility");
859
+ const {
860
+ buildCoveringIndexDefinition,
861
+ indexSatisfiesCoveringDefinition,
862
+ } = require("./utility");
860
863
 
861
864
  // 1. Run EXPLAIN to get the query plan
862
865
  const explainQuery = `EXPLAIN ${query}`;
@@ -870,16 +873,48 @@ module.exports = class Database {
870
873
  }
871
874
 
872
875
  // 3. Build covering index from the query
873
- const indexStatement = buildCoveringIndex(query, {
876
+ const candidate = buildCoveringIndexDefinition(query, {
874
877
  bucket: this.bucket,
875
878
  scope: this.scope_name,
876
879
  collection: this.collection_name,
877
880
  });
878
881
 
879
- // 4. Execute the index creation if we got a valid statement
880
- if (indexStatement) {
881
- await exec_scope.query(indexStatement);
882
+ if (!candidate) return;
883
+
884
+ // EXPLAIN only describes the chosen plan. An equivalent or wider index
885
+ // may already exist under an older name, or still be deferred/building.
886
+ // Include both catalog representations of the default collection.
887
+ const indexResponse = await exec_scope.query(
888
+ `SELECT RAW i FROM system:indexes AS i
889
+ WHERE i.namespace_id = 'default'
890
+ AND ((i.bucket_id = $bucket AND i.scope_id = $scope AND i.keyspace_id = $collection)
891
+ OR ($scope = '_default' AND $collection = '_default'
892
+ AND i.bucket_id IS MISSING AND i.scope_id IS MISSING AND i.keyspace_id = $bucket))`,
893
+ {
894
+ parameters: {
895
+ bucket: candidate.bucket,
896
+ scope: candidate.scope,
897
+ collection: candidate.collection,
898
+ },
899
+ },
900
+ );
901
+ // A failed or incomplete inventory is not evidence that no index exists.
902
+ if (
903
+ !Array.isArray(indexResponse?.rows) ||
904
+ indexResponse?.meta?.warnings?.length
905
+ ) {
906
+ throw new Error("Cannot verify existing indexes before auto-optimization");
907
+ }
908
+ if (indexResponse.rows.some((index) =>
909
+ index.name === candidate.name ||
910
+ indexSatisfiesCoveringDefinition(index, candidate),
911
+ )) {
912
+ return;
882
913
  }
914
+
915
+ // Identical definitions use the same name across processes. Couchbase's
916
+ // per-keyspace name uniqueness also prevents a concurrent duplicate.
917
+ await exec_scope.query(candidate.statement);
883
918
  } catch (e) {
884
919
  // A failure here (timeout, index already exists, cluster busy) almost
885
920
  // always means the cluster is struggling. Stop adding EXPLAIN and
package/src/utility.js CHANGED
@@ -245,22 +245,17 @@ function buildOrderDirectionMap(orderByIndexFields) {
245
245
  const directionMap = new Map();
246
246
  for (const { name, direction } of orderByIndexFields) {
247
247
  if (direction === "DESC") {
248
- directionMap.set(name.toLowerCase(), direction);
248
+ directionMap.set(name, direction);
249
249
  }
250
250
  }
251
251
  return directionMap;
252
252
  }
253
253
 
254
254
  function formatIndexField(field, orderDirectionMap) {
255
- const direction = orderDirectionMap.get(field.toLowerCase());
255
+ const direction = orderDirectionMap.get(field);
256
256
  return `\`${field}\`${direction ? ` ${direction}` : ""}`;
257
257
  }
258
258
 
259
- function formatIndexKey(field, orderDirectionMap) {
260
- const direction = orderDirectionMap.get(field.toLowerCase());
261
- return `${field}${direction ? ` ${direction}` : ""}`;
262
- }
263
-
264
259
  /**
265
260
  * Splits SELECT clause by commas, respecting parentheses
266
261
  * @param {string} clause - The SELECT clause content
@@ -293,23 +288,8 @@ function splitSelectClause(clause) {
293
288
  return parts;
294
289
  }
295
290
 
296
- function hasQualifiedWildcardProjection(query) {
297
- const selectMatch = query.match(/\bSELECT\b(.+?)\bFROM\b/is);
298
- if (!selectMatch) {
299
- return false;
300
- }
301
-
302
- return splitSelectClause(selectMatch[1].trim()).some((part) => {
303
- const trimmedPart = part.trim();
304
- return (
305
- /^\(\s*`?\w+`?\s*\)\s*\.\s*\*$/.test(trimmedPart) ||
306
- /^`?\w+`?\s*\.\s*\*$/.test(trimmedPart)
307
- );
308
- });
309
- }
310
-
311
291
  /**
312
- * Builds a covering or fallback non-covering index CREATE statement from a query
292
+ * Builds a covering index CREATE statement from a query
313
293
  * @param {string} query - The N1QL query to analyze
314
294
  * @param {Object} options - Configuration options
315
295
  * @param {string} options.bucket - The bucket name
@@ -318,6 +298,12 @@ function hasQualifiedWildcardProjection(query) {
318
298
  * @returns {string|null} - The CREATE INDEX statement, or null if cannot build
319
299
  */
320
300
  function buildCoveringIndex(query, options) {
301
+ return buildCoveringIndexDefinition(query, options)?.statement ?? null;
302
+ }
303
+
304
+ // Keep the access keys separate from projection-only keys so an older, wider
305
+ // index can satisfy the same request without depending on its generated name.
306
+ function buildCoveringIndexDefinition(query, options) {
321
307
  const { bucket, scope = "_default", collection = "_default" } = options;
322
308
 
323
309
  if (!bucket) {
@@ -326,9 +312,9 @@ function buildCoveringIndex(query, options) {
326
312
 
327
313
  // Extract fields from SELECT clause
328
314
  const selectFields = extractSelectFields(query);
329
- const isNonCoveringFallback =
330
- selectFields === null && hasQualifiedWildcardProjection(query);
331
- if (selectFields === null && !isNonCoveringFallback) {
315
+ if (selectFields === null) {
316
+ // Full-document projections cannot be covered by the extracted fields.
317
+ // Skip them instead of creating a non-covering fallback index.
332
318
  return null;
333
319
  }
334
320
 
@@ -344,44 +330,41 @@ function buildCoveringIndex(query, options) {
344
330
  const orderByFields = orderByIndexFields.map((field) => field.name);
345
331
 
346
332
  // Remove 'deleted' from whereFields as it will be added first with INCLUDE MISSING
347
- const filteredWhereFields = whereFields.filter(
348
- (f) => f.toLowerCase() !== "deleted",
349
- );
333
+ const filteredWhereFields = whereFields.filter((f) => f !== "deleted");
350
334
 
351
335
  // Track fields already added to avoid duplicates
352
- const addedFields = new Set(filteredWhereFields.map((f) => f.toLowerCase()));
336
+ const addedFields = new Set(filteredWhereFields);
353
337
 
354
338
  // Filter GROUP BY fields (remove duplicates from WHERE, remove deleted)
355
339
  const filteredGroupByFields = groupByFields.filter((f) => {
356
- const lower = f.toLowerCase();
357
- if (addedFields.has(lower) || lower === "deleted") {
340
+ if (addedFields.has(f) || f === "deleted") {
358
341
  return false;
359
342
  }
360
- addedFields.add(lower);
343
+ addedFields.add(f);
361
344
  return true;
362
345
  });
363
346
 
364
347
  // Filter ORDER BY fields (remove duplicates from WHERE and GROUP BY, remove deleted)
365
348
  const filteredOrderByFields = orderByFields.filter((f) => {
366
- const lower = f.toLowerCase();
367
- if (addedFields.has(lower) || lower === "deleted") {
349
+ if (addedFields.has(f) || f === "deleted") {
368
350
  return false;
369
351
  }
370
- addedFields.add(lower);
352
+ addedFields.add(f);
371
353
  return true;
372
354
  });
373
355
 
374
356
  // Filter SELECT fields (remove duplicates from WHERE, GROUP BY, and ORDER BY, remove deleted)
375
- const selectFieldsFiltered = (selectFields || []).filter((f) => {
376
- const lower = f.toLowerCase();
377
- if (addedFields.has(lower) || lower === "deleted") {
378
- return false;
379
- }
380
- addedFields.add(lower);
381
- return true;
382
- });
357
+ const selectFieldsFiltered = selectFields
358
+ .filter((f) => {
359
+ if (addedFields.has(f) || f === "deleted") {
360
+ return false;
361
+ }
362
+ addedFields.add(f);
363
+ return true;
364
+ })
365
+ .sort();
383
366
 
384
- // All fields for the index: WHERE -> GROUP BY -> ORDER BY -> SELECT
367
+ // Projection order has no effect on coverage. Keep access-key order intact.
385
368
  const allFields = [
386
369
  ...filteredWhereFields,
387
370
  ...filteredGroupByFields,
@@ -395,33 +378,90 @@ function buildCoveringIndex(query, options) {
395
378
  }
396
379
 
397
380
  // Build the field list with deleted INCLUDE MISSING first
398
- const fieldList = [
381
+ const keys = [
399
382
  "`deleted` INCLUDE MISSING",
400
383
  ...allFields.map((f) => formatIndexField(f, orderDirectionMap)),
401
- ].join(", ");
402
-
403
- // Generate unique index name based on bucket, scope, collection, and fields
404
- // This ensures multiple queries covered by the same index get the same hash
405
- const indexFieldsKey = allFields
406
- .map((field) => formatIndexKey(field, orderDirectionMap))
407
- .sort()
408
- .join(",");
409
- const orderFieldsKey = orderByIndexFields
410
- .map((field) => formatIndexKey(field.name, orderDirectionMap))
411
- .join(",");
412
- const indexMode = isNonCoveringFallback ? "noncovered" : "covered";
413
- const indexKey = `${bucket}|${scope}|${collection}|mode:${indexMode}|fields:${indexFieldsKey}|order:${orderFieldsKey}`;
384
+ ];
385
+
386
+ // Hash the actual ordered definition, not query syntax. In particular, an
387
+ // explicit ORDER BY ... ASC must not rename an otherwise identical index.
388
+ const indexKey = JSON.stringify([bucket, scope, collection, keys]);
414
389
  const hash = generateHash(indexKey);
415
- const indexName = `adv_${indexMode}_${hash}`;
390
+ const indexName = `adv_covered_${hash}`;
416
391
 
417
392
  // Build the CREATE INDEX statement
418
- const indexStatement = `CREATE INDEX \`${indexName}\` ON \`default\`:\`${bucket}\`.\`${scope}\`.\`${collection}\`(${fieldList})`;
393
+ const quote = (identifier) => `\`${identifier.replace(/`/g, "``")}\``;
394
+ const indexStatement = `CREATE INDEX \`${indexName}\` ON \`default\`:${quote(bucket)}.${quote(scope)}.${quote(collection)}(${keys.join(", ")})`;
395
+
396
+ return {
397
+ name: indexName,
398
+ statement: indexStatement,
399
+ keys,
400
+ accessKeyCount:
401
+ 1 + filteredWhereFields.length + filteredGroupByFields.length + filteredOrderByFields.length,
402
+ bucket,
403
+ scope,
404
+ collection,
405
+ };
406
+ }
419
407
 
420
- return indexStatement;
408
+ // Only compare simple scalar keys. Do not treat expressions, array indexes,
409
+ // partial indexes, or case-distinct document fields as equivalent.
410
+ function normalizeIndexKey(key) {
411
+ if (typeof key !== "string") return null;
412
+ const match = key.trim().match(
413
+ /^(\(*\s*(?:`(?:[^`]|``)+`|[A-Za-z_]\w*)\s*\)*)(.*?)$/s,
414
+ );
415
+ if (!match) return null;
416
+ let expression = match[1].trim();
417
+ while (expression.startsWith("(") && expression.endsWith(")")) {
418
+ expression = expression.slice(1, -1).trim();
419
+ }
420
+ const identifier = expression.match(/^(?:`((?:[^`]|``)+)`|([A-Za-z_]\w*))$/);
421
+ if (!identifier) return null;
422
+ const attributes = match[2].trim().toUpperCase();
423
+ if (!/^(?:(?:ASC|DESC)(?:\s+INCLUDE\s+MISSING)?|INCLUDE\s+MISSING(?:\s+(?:ASC|DESC))?)?$/.test(attributes)) {
424
+ return null;
425
+ }
426
+ return {
427
+ field: identifier[1] !== undefined
428
+ ? identifier[1].replace(/``/g, "`")
429
+ : identifier[2],
430
+ direction: /\bDESC\b/.test(attributes) ? "DESC" : "ASC",
431
+ includeMissing: /\bINCLUDE\s+MISSING\b/.test(attributes),
432
+ };
433
+ }
434
+
435
+ // Call with indexes from the candidate's exact bucket/scope/collection. State
436
+ // is deliberately not filtered: a matching deferred/building index is already
437
+ // committed work, even though EXPLAIN cannot use it yet.
438
+ function indexSatisfiesCoveringDefinition(index, candidate) {
439
+ if (
440
+ index.is_primary || index.condition || index.using !== "gsi" ||
441
+ !Array.isArray(index.index_key)
442
+ ) {
443
+ return false;
444
+ }
445
+ const existingKeys = index.index_key.map(normalizeIndexKey);
446
+ const requiredKeys = candidate.keys.map(normalizeIndexKey);
447
+ if (existingKeys.some((key) => !key) || requiredKeys.some((key) => !key)) return false;
448
+ for (let i = 0; i < candidate.accessKeyCount; i++) {
449
+ const existing = existingKeys[i];
450
+ const required = requiredKeys[i];
451
+ if (
452
+ !existing || existing.field !== required.field ||
453
+ existing.direction !== required.direction ||
454
+ existing.includeMissing !== required.includeMissing
455
+ ) return false;
456
+ }
457
+ const existingFields = new Set(existingKeys.map((key) => key.field));
458
+ return requiredKeys.slice(candidate.accessKeyCount).every((key) => existingFields.has(key.field));
421
459
  }
422
460
 
423
461
  module.exports = {
424
462
  buildCoveringIndex,
463
+ buildCoveringIndexDefinition,
464
+ indexSatisfiesCoveringDefinition,
425
465
  extractWhereFields,
426
466
  extractSelectFields,
427
467
  extractGroupByFields,
@@ -599,16 +599,16 @@ test("returns null when missing bucket option", () => {
599
599
  assert.strictEqual(result, null);
600
600
  });
601
601
 
602
- test("returns null when missing scope option", () => {
602
+ test("uses the default scope when scope option is missing", () => {
603
603
  const query = "SELECT name FROM users WHERE status = 'active'";
604
604
  const result = buildCoveringIndex(query, { bucket: "test", collection: "users" });
605
- assert.strictEqual(result, null);
605
+ assert.ok(result.includes("`test`.`_default`.`users`"));
606
606
  });
607
607
 
608
- test("returns null when missing collection option", () => {
608
+ test("uses the default collection when collection option is missing", () => {
609
609
  const query = "SELECT name FROM users WHERE status = 'active'";
610
610
  const result = buildCoveringIndex(query, { bucket: "test", scope: "scope" });
611
- assert.strictEqual(result, null);
611
+ assert.ok(result.includes("`test`.`scope`.`_default`"));
612
612
  });
613
613
 
614
614
  test("generates unique index name from query hash", () => {