spice-couchbase 2.0.63 → 2.0.65

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/utility.js CHANGED
@@ -1,220 +1,203 @@
1
1
  "use strict";
2
2
 
3
3
  var crypto = require("crypto");
4
+
4
5
  /**
5
6
  * Generates a short hash from a string for unique index naming
6
7
  * @param {string} str - The string to hash
7
8
  * @returns {string} - A short hash string
8
9
  */
9
-
10
-
11
10
  function generateHash(str) {
12
11
  return crypto.createHash("md5").update(str).digest("hex").substring(0, 8);
13
12
  }
13
+
14
14
  /**
15
15
  * Extracts field names from WHERE clause of a query
16
16
  * @param {string} query - The N1QL query
17
17
  * @returns {string[]} - Array of field names found in WHERE clause
18
18
  */
19
-
20
-
21
19
  function extractWhereFields(query) {
22
- var fields = new Set(); // Find the WHERE clause
23
-
24
- var whereMatch = query.match(/\bWHERE\b([\s\S]+?)(?:\bORDER\b|\bLIMIT\b|\bOFFSET\b|\bGROUP\b|\bHAVING\b|$)/i);
20
+ var fields = new Set();
25
21
 
22
+ // Find the WHERE clause
23
+ var whereMatch = query.match(/\bWHERE\b([^]+?)(?:\bORDER\b|\bLIMIT\b|\bOFFSET\b|\bGROUP\b|\bHAVING\b|$)/i);
26
24
  if (!whereMatch) {
27
25
  return [];
28
26
  }
27
+ var whereClause = whereMatch[1];
29
28
 
30
- var whereClause = whereMatch[1]; // Pattern to match field names in comparisons
29
+ // Pattern to match field names in comparisons
31
30
  // Matches: field = value, field > value, field < value, field >= value, field <= value
32
31
  // Also matches: field IN (...), field LIKE ..., field IS NULL, field IS MISSING, field IS NOT NULL
33
32
  // Handles backtick-quoted fields like `field_name`
34
-
35
33
  var fieldPattern = /`?(\w+)`?\s*(?:=|!=|<>|>=|<=|>|<|\bIN\b|\bLIKE\b|\bIS\b|\bBETWEEN\b)/gi;
36
34
  var match;
37
-
38
35
  while ((match = fieldPattern.exec(whereClause)) !== null) {
39
- var fieldName = match[1].toLowerCase(); // Skip common SQL keywords that might be matched
40
-
36
+ var fieldName = match[1].toLowerCase();
37
+ // Skip common SQL keywords that might be matched
41
38
  if (!["and", "or", "not", "null", "missing", "true", "false"].includes(fieldName)) {
42
39
  fields.add(match[1]); // Keep original case
43
40
  }
44
- } // Also look for fields in function calls like LOWER(field), UPPER(field)
45
-
41
+ }
46
42
 
43
+ // Also look for fields in function calls like LOWER(field), UPPER(field)
47
44
  var functionPattern = /(?:LOWER|UPPER|TRIM|LENGTH|SUBSTR)\s*\(\s*`?(\w+)`?/gi;
48
-
49
45
  while ((match = functionPattern.exec(whereClause)) !== null) {
50
46
  fields.add(match[1]);
51
- } // Extract array fields from ANY/EVERY ... IN array_field SATISFIES ... END
52
- // Pattern: ANY/EVERY variable IN array_field SATISFIES ... END
53
-
47
+ }
54
48
 
49
+ // Extract array fields from ANY/EVERY ... IN array_field SATISFIES ... END
50
+ // Pattern: ANY/EVERY variable IN array_field SATISFIES ... END
55
51
  var anyEveryPattern = /\b(?:ANY|EVERY)\s+\w+\s+IN\s+`?(\w+)`?\s+SATISFIES/gi;
56
-
57
52
  while ((match = anyEveryPattern.exec(whereClause)) !== null) {
58
53
  fields.add(match[1]);
59
- } // Also extract nested property access within SATISFIES: v.property = value
60
- // This captures fields accessed through array iteration variables
61
-
54
+ }
62
55
 
56
+ // Also extract nested property access within SATISFIES: v.property = value
57
+ // This captures fields accessed through array iteration variables
63
58
  var satisfiesPattern = /SATISFIES\s+(.+?)\s+END/gi;
64
-
65
59
  while ((match = satisfiesPattern.exec(whereClause)) !== null) {
66
- var satisfiesClause = match[1]; // Look for comparisons like: v.property = value, v.field > 10
67
-
60
+ var satisfiesClause = match[1];
61
+ // Look for comparisons like: v.property = value, v.field > 10
68
62
  var nestedFieldPattern = /\w+\.`?(\w+)`?\s*(?:=|!=|<>|>=|<=|>|<|\bIN\b|\bLIKE\b|\bIS\b)/gi;
69
63
  var nestedMatch = void 0;
70
-
71
- while ((nestedMatch = nestedFieldPattern.exec(satisfiesClause)) !== null) {// These are nested properties, we might want to track them separately
64
+ while ((nestedMatch = nestedFieldPattern.exec(satisfiesClause)) !== null) {
65
+ // These are nested properties, we might want to track them separately
72
66
  // For now, we'll note them but the array field is what matters for indexing
73
67
  }
74
68
  }
75
-
76
69
  return Array.from(fields);
77
70
  }
71
+
78
72
  /**
79
73
  * Extracts field names from SELECT clause of a query
80
74
  * @param {string} query - The N1QL query
81
75
  * @returns {string[]|null} - Array of field names, or null if SELECT *
82
76
  */
83
-
84
-
85
77
  function extractSelectFields(query) {
86
78
  // Find the SELECT clause (between SELECT and FROM)
87
- var selectMatch = query.match(/\bSELECT\b([\s\S]+?)\bFROM\b/i);
88
-
79
+ var selectMatch = query.match(/\bSELECT\b([^]+?)\bFROM\b/i);
89
80
  if (!selectMatch) {
90
81
  return null;
91
82
  }
83
+ var selectClause = selectMatch[1].trim();
92
84
 
93
- var selectClause = selectMatch[1].trim(); // Check for SELECT * patterns
94
-
85
+ // Check for SELECT * patterns
95
86
  if (/^\s*\*\s*$/.test(selectClause) || /\(\s*\w+\s*\)\s*\.\s*\*/.test(selectClause)) {
96
87
  return null; // Cannot determine fields for SELECT *
97
88
  }
89
+ var fields = new Set();
98
90
 
99
- var fields = new Set(); // Split by comma, handling nested parentheses
100
-
91
+ // Split by comma, handling nested parentheses
101
92
  var parts = splitSelectClause(selectClause);
102
-
103
93
  for (var part of parts) {
104
- var trimmedPart = part.trim(); // Skip if it's a * pattern
94
+ var trimmedPart = part.trim();
105
95
 
96
+ // Skip if it's a * pattern
106
97
  if (trimmedPart === "*" || trimmedPart.endsWith(".*")) {
107
98
  return null;
108
- } // Handle AS alias - get the source field
109
-
99
+ }
110
100
 
101
+ // Handle AS alias - get the source field
111
102
  var asMatch = trimmedPart.match(/^(.+?)\s+AS\s+\w+$/i);
112
- var fieldExpr = asMatch ? asMatch[1].trim() : trimmedPart; // Extract field from META().id pattern - skip these
103
+ var fieldExpr = asMatch ? asMatch[1].trim() : trimmedPart;
113
104
 
105
+ // Extract field from META().id pattern - skip these
114
106
  if (/META\s*\(/i.test(fieldExpr)) {
115
107
  continue;
116
- } // Extract field from function calls like COUNT(field), SUM(field)
117
-
108
+ }
118
109
 
110
+ // Extract field from function calls like COUNT(field), SUM(field)
119
111
  var funcMatch = fieldExpr.match(/^\w+\s*\(\s*`?(\w+)`?\s*\)$/);
120
-
121
112
  if (funcMatch && funcMatch[1].toLowerCase() !== "id") {
122
113
  fields.add(funcMatch[1]);
123
114
  continue;
124
- } // Handle simple field reference: `field` or collection.field or `collection`.`field`
125
-
115
+ }
126
116
 
117
+ // Handle simple field reference: `field` or collection.field or `collection`.`field`
127
118
  var simpleFieldMatch = fieldExpr.match(/(?:`?\w+`?\s*\.\s*)?`?(\w+)`?$/);
128
-
129
119
  if (simpleFieldMatch) {
130
120
  var fieldName = simpleFieldMatch[1];
131
-
132
121
  if (fieldName.toLowerCase() !== "id") {
133
122
  fields.add(fieldName);
134
123
  }
135
124
  }
136
125
  }
137
-
138
126
  return Array.from(fields);
139
127
  }
128
+
140
129
  /**
141
130
  * Extracts field names from GROUP BY clause of a query
142
131
  * @param {string} query - The N1QL query
143
132
  * @returns {string[]} - Array of field names found in GROUP BY clause
144
133
  */
145
-
146
-
147
134
  function extractGroupByFields(query) {
148
- var fields = []; // Find the GROUP BY clause
149
-
150
- var groupByMatch = query.match(/\bGROUP[\t-\r \xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+BY\b([\s\S]+?)(?:\bHAVING\b|\bORDER\b|\bLIMIT\b|\bOFFSET\b|$)/i);
135
+ var fields = [];
151
136
 
137
+ // Find the GROUP BY clause
138
+ var groupByMatch = query.match(/\bGROUP\s+BY\b([^]+?)(?:\bHAVING\b|\bORDER\b|\bLIMIT\b|\bOFFSET\b|$)/i);
152
139
  if (!groupByMatch) {
153
140
  return [];
154
141
  }
142
+ var groupByClause = groupByMatch[1];
155
143
 
156
- var groupByClause = groupByMatch[1]; // Split by comma and extract field names
157
-
144
+ // Split by comma and extract field names
158
145
  var parts = groupByClause.split(",");
159
-
160
146
  for (var part of parts) {
161
- var trimmedPart = part.trim(); // Handle collection.field or `collection`.`field` syntax
147
+ var trimmedPart = part.trim();
162
148
 
149
+ // Handle collection.field or `collection`.`field` syntax
163
150
  var fieldMatch = trimmedPart.match(/(?:`?\w+`?\s*\.\s*)?`?(\w+)`?$/);
164
-
165
151
  if (fieldMatch) {
166
- var fieldName = fieldMatch[1]; // Skip if it's a keyword or id
167
-
152
+ var fieldName = fieldMatch[1];
153
+ // Skip if it's a keyword or id
168
154
  if (!["id"].includes(fieldName.toLowerCase())) {
169
155
  fields.push(fieldName);
170
156
  }
171
157
  }
172
158
  }
173
-
174
159
  return fields;
175
160
  }
161
+
176
162
  /**
177
163
  * Extracts field names from ORDER BY clause of a query
178
164
  * @param {string} query - The N1QL query
179
165
  * @returns {string[]} - Array of field names found in ORDER BY clause
180
166
  */
181
-
182
-
183
167
  function extractOrderByFields(query) {
184
168
  return extractOrderByIndexFields(query).map(field => field.name);
185
169
  }
170
+
186
171
  /**
187
172
  * Extracts ORDER BY fields with sort direction for index key generation
188
173
  * @param {string} query - The N1QL query
189
174
  * @returns {{name: string, direction: string}[]} - Array of fields with normalized direction
190
175
  */
191
-
192
-
193
176
  function extractOrderByIndexFields(query) {
194
- var fields = []; // Find the ORDER BY clause
195
-
196
- var orderByMatch = query.match(/\bORDER[\t-\r \xA0\u1680\u2000-\u200A\u2028\u2029\u202F\u205F\u3000\uFEFF]+BY\b([\s\S]+?)(?:\bLIMIT\b|\bOFFSET\b|$)/i);
177
+ var fields = [];
197
178
 
179
+ // Find the ORDER BY clause
180
+ var orderByMatch = query.match(/\bORDER\s+BY\b([^]+?)(?:\bLIMIT\b|\bOFFSET\b|$)/i);
198
181
  if (!orderByMatch) {
199
182
  return [];
200
183
  }
184
+ var orderByClause = orderByMatch[1];
201
185
 
202
- var orderByClause = orderByMatch[1]; // Split by comma and extract field names
203
-
186
+ // Split by comma and extract field names
204
187
  var parts = orderByClause.split(",");
205
-
206
188
  for (var part of parts) {
207
- var trimmedPart = part.trim(); // Remove ASC/DESC suffix while preserving DESC for index key generation.
189
+ var trimmedPart = part.trim();
208
190
 
191
+ // Remove ASC/DESC suffix while preserving DESC for index key generation.
209
192
  var directionMatch = trimmedPart.match(/\s+(ASC|DESC)\s*$/i);
210
193
  var direction = directionMatch && directionMatch[1].toUpperCase() === "DESC" ? "DESC" : "";
211
- var withoutDirection = trimmedPart.replace(/\s+(ASC|DESC)\s*$/i, "").trim(); // Handle collection.field or `collection`.`field` syntax
194
+ var withoutDirection = trimmedPart.replace(/\s+(ASC|DESC)\s*$/i, "").trim();
212
195
 
196
+ // Handle collection.field or `collection`.`field` syntax
213
197
  var fieldMatch = withoutDirection.match(/(?:`?\w+`?\s*\.\s*)?`?(\w+)`?$/);
214
-
215
198
  if (fieldMatch) {
216
- var fieldName = fieldMatch[1]; // Skip if it's a keyword or id
217
-
199
+ var fieldName = fieldMatch[1];
200
+ // Skip if it's a keyword or id
218
201
  if (!["id", "asc", "desc"].includes(fieldName.toLowerCase())) {
219
202
  fields.push({
220
203
  name: fieldName,
@@ -223,41 +206,33 @@ function extractOrderByIndexFields(query) {
223
206
  }
224
207
  }
225
208
  }
226
-
227
209
  return fields;
228
210
  }
229
-
230
211
  function buildOrderDirectionMap(orderByIndexFields) {
231
212
  var directionMap = new Map();
232
-
233
- for (var {
234
- name,
235
- direction
236
- } of orderByIndexFields) {
213
+ for (var _ref2 of orderByIndexFields) {
214
+ var name = _ref2.name;
215
+ var direction = _ref2.direction;
237
216
  if (direction === "DESC") {
238
217
  directionMap.set(name, direction);
239
218
  }
240
219
  }
241
-
242
220
  return directionMap;
243
221
  }
244
-
245
222
  function formatIndexField(field, orderDirectionMap) {
246
223
  var direction = orderDirectionMap.get(field);
247
224
  return "`".concat(field, "`").concat(direction ? " ".concat(direction) : "");
248
225
  }
226
+
249
227
  /**
250
228
  * Splits SELECT clause by commas, respecting parentheses
251
229
  * @param {string} clause - The SELECT clause content
252
230
  * @returns {string[]} - Array of individual select expressions
253
231
  */
254
-
255
-
256
232
  function splitSelectClause(clause) {
257
233
  var parts = [];
258
234
  var current = "";
259
235
  var depth = 0;
260
-
261
236
  for (var char of clause) {
262
237
  if (char === "(") {
263
238
  depth++;
@@ -272,13 +247,12 @@ function splitSelectClause(clause) {
272
247
  current += char;
273
248
  }
274
249
  }
275
-
276
250
  if (current.trim()) {
277
251
  parts.push(current);
278
252
  }
279
-
280
253
  return parts;
281
254
  }
255
+
282
256
  /**
283
257
  * Builds a covering index CREATE statement from a query
284
258
  * @param {string} query - The N1QL query to analyze
@@ -288,93 +262,93 @@ function splitSelectClause(clause) {
288
262
  * @param {string} options.collection - The collection name
289
263
  * @returns {string|null} - The CREATE INDEX statement, or null if cannot build
290
264
  */
291
-
292
-
293
265
  function buildCoveringIndex(query, options) {
294
- var _ref, _buildCoveringIndexDe;
266
+ var _buildCoveringIndexDe, _buildCoveringIndexDe2;
267
+ return (_buildCoveringIndexDe = (_buildCoveringIndexDe2 = buildCoveringIndexDefinition(query, options)) === null || _buildCoveringIndexDe2 === void 0 ? void 0 : _buildCoveringIndexDe2.statement) !== null && _buildCoveringIndexDe !== void 0 ? _buildCoveringIndexDe : null;
268
+ }
295
269
 
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
270
+ // Keep the access keys separate from projection-only keys so an older, wider
298
271
  // index can satisfy the same request without depending on its generated name.
299
-
300
-
301
272
  function buildCoveringIndexDefinition(query, options) {
302
- var {
303
- bucket,
304
- scope = "_default",
305
- collection = "_default"
306
- } = options;
307
-
273
+ var bucket = options.bucket,
274
+ _options$scope = options.scope,
275
+ scope = _options$scope === void 0 ? "_default" : _options$scope,
276
+ _options$collection = options.collection,
277
+ collection = _options$collection === void 0 ? "_default" : _options$collection;
308
278
  if (!bucket) {
309
279
  return null;
310
- } // Extract fields from SELECT clause
311
-
280
+ }
312
281
 
282
+ // Extract fields from SELECT clause
313
283
  var selectFields = extractSelectFields(query);
314
-
315
284
  if (selectFields === null) {
316
285
  // Full-document projections cannot be covered by the extracted fields.
317
286
  // Skip them instead of creating a non-covering fallback index.
318
287
  return null;
319
- } // Extract fields from WHERE clause
320
-
288
+ }
321
289
 
322
- var whereFields = extractWhereFields(query); // Extract fields from GROUP BY clause
290
+ // Extract fields from WHERE clause
291
+ var whereFields = extractWhereFields(query);
323
292
 
324
- var groupByFields = extractGroupByFields(query); // Extract fields from ORDER BY clause, preserving DESC sort direction
293
+ // Extract fields from GROUP BY clause
294
+ var groupByFields = extractGroupByFields(query);
325
295
 
296
+ // Extract fields from ORDER BY clause, preserving DESC sort direction
326
297
  var orderByIndexFields = extractOrderByIndexFields(query);
327
298
  var orderDirectionMap = buildOrderDirectionMap(orderByIndexFields);
328
- var orderByFields = orderByIndexFields.map(field => field.name); // Remove 'deleted' from whereFields as it will be added first with INCLUDE MISSING
299
+ var orderByFields = orderByIndexFields.map(field => field.name);
329
300
 
330
- var filteredWhereFields = whereFields.filter(f => f !== "deleted"); // Track fields already added to avoid duplicates
301
+ // Remove 'deleted' from whereFields as it will be added first with INCLUDE MISSING
302
+ var filteredWhereFields = whereFields.filter(f => f !== "deleted");
331
303
 
332
- var addedFields = new Set(filteredWhereFields); // Filter GROUP BY fields (remove duplicates from WHERE, remove deleted)
304
+ // Track fields already added to avoid duplicates
305
+ var addedFields = new Set(filteredWhereFields);
333
306
 
307
+ // Filter GROUP BY fields (remove duplicates from WHERE, remove deleted)
334
308
  var filteredGroupByFields = groupByFields.filter(f => {
335
309
  if (addedFields.has(f) || f === "deleted") {
336
310
  return false;
337
311
  }
338
-
339
312
  addedFields.add(f);
340
313
  return true;
341
- }); // Filter ORDER BY fields (remove duplicates from WHERE and GROUP BY, remove deleted)
314
+ });
342
315
 
316
+ // Filter ORDER BY fields (remove duplicates from WHERE and GROUP BY, remove deleted)
343
317
  var filteredOrderByFields = orderByFields.filter(f => {
344
318
  if (addedFields.has(f) || f === "deleted") {
345
319
  return false;
346
320
  }
347
-
348
321
  addedFields.add(f);
349
322
  return true;
350
- }); // Filter SELECT fields (remove duplicates from WHERE, GROUP BY, and ORDER BY, remove deleted)
323
+ });
351
324
 
325
+ // Filter SELECT fields (remove duplicates from WHERE, GROUP BY, and ORDER BY, remove deleted)
352
326
  var selectFieldsFiltered = selectFields.filter(f => {
353
327
  if (addedFields.has(f) || f === "deleted") {
354
328
  return false;
355
329
  }
356
-
357
330
  addedFields.add(f);
358
331
  return true;
359
- }).sort(); // Projection order has no effect on coverage. Keep access-key order intact.
332
+ }).sort();
360
333
 
334
+ // Projection order has no effect on coverage. Keep access-key order intact.
361
335
  var allFields = [...filteredWhereFields, ...filteredGroupByFields, ...filteredOrderByFields, ...selectFieldsFiltered];
362
-
363
336
  if (allFields.length === 0) {
364
337
  // No fields to index besides deleted
365
338
  return null;
366
- } // Build the field list with deleted INCLUDE MISSING first
339
+ }
367
340
 
341
+ // Build the field list with deleted INCLUDE MISSING first
342
+ var keys = ["`deleted` INCLUDE MISSING", ...allFields.map(f => formatIndexField(f, orderDirectionMap))];
368
343
 
369
- var keys = ["`deleted` INCLUDE MISSING", ...allFields.map(f => formatIndexField(f, orderDirectionMap))]; // Hash the actual ordered definition, not query syntax. In particular, an
344
+ // Hash the actual ordered definition, not query syntax. In particular, an
370
345
  // explicit ORDER BY ... ASC must not rename an otherwise identical index.
371
-
372
346
  var indexKey = JSON.stringify([bucket, scope, collection, keys]);
373
347
  var hash = generateHash(indexKey);
374
- var indexName = "adv_covered_".concat(hash); // Build the CREATE INDEX statement
348
+ var indexName = "adv_covered_".concat(hash);
375
349
 
350
+ // Build the CREATE INDEX statement
376
351
  var quote = identifier => "`".concat(identifier.replace(/`/g, "``"), "`");
377
-
378
352
  var indexStatement = "CREATE INDEX `".concat(indexName, "` ON `default`:").concat(quote(bucket), ".").concat(quote(scope), ".").concat(quote(collection), "(").concat(keys.join(", "), ")");
379
353
  return {
380
354
  name: indexName,
@@ -385,56 +359,50 @@ function buildCoveringIndexDefinition(query, options) {
385
359
  scope,
386
360
  collection
387
361
  };
388
- } // Only compare simple scalar keys. Do not treat expressions, array indexes,
389
- // partial indexes, or case-distinct document fields as equivalent.
390
-
362
+ }
391
363
 
364
+ // Only compare simple scalar keys. Do not treat expressions, array indexes,
365
+ // partial indexes, or case-distinct document fields as equivalent.
392
366
  function normalizeIndexKey(key) {
393
367
  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]*?)$/);
368
+ var match = key.trim().match(/^(\(*\s*(?:`(?:[^`]|``)+`|[A-Za-z_]\w*)\s*\)*)([^]*?)$/);
395
369
  if (!match) return null;
396
370
  var expression = match[1].trim();
397
-
398
371
  while (expression.startsWith("(") && expression.endsWith(")")) {
399
372
  expression = expression.slice(1, -1).trim();
400
373
  }
401
-
402
374
  var identifier = expression.match(/^(?:`((?:[^`]|``)+)`|([A-Za-z_]\w*))$/);
403
375
  if (!identifier) return null;
404
376
  var attributes = match[2].trim().toUpperCase();
405
-
406
377
  if (!/^(?:(?:ASC|DESC)(?:\s+INCLUDE\s+MISSING)?|INCLUDE\s+MISSING(?:\s+(?:ASC|DESC))?)?$/.test(attributes)) {
407
378
  return null;
408
379
  }
409
-
410
380
  return {
411
381
  field: identifier[1] !== undefined ? identifier[1].replace(/``/g, "`") : identifier[2],
412
382
  direction: /\bDESC\b/.test(attributes) ? "DESC" : "ASC",
413
383
  includeMissing: /\bINCLUDE\s+MISSING\b/.test(attributes)
414
384
  };
415
- } // Call with indexes from the candidate's exact bucket/scope/collection. State
385
+ }
386
+
387
+ // Call with indexes from the candidate's exact bucket/scope/collection. State
416
388
  // is deliberately not filtered: a matching deferred/building index is already
417
389
  // committed work, even though EXPLAIN cannot use it yet.
418
-
419
-
420
390
  function indexSatisfiesCoveringDefinition(index, candidate) {
421
391
  if (index.is_primary || index.condition || index.using !== "gsi" || !Array.isArray(index.index_key)) {
422
392
  return false;
423
393
  }
424
-
425
394
  var existingKeys = index.index_key.map(normalizeIndexKey);
426
395
  var requiredKeys = candidate.keys.map(normalizeIndexKey);
427
396
  if (existingKeys.some(key => !key) || requiredKeys.some(key => !key)) return false;
428
-
429
397
  for (var i = 0; i < candidate.accessKeyCount; i++) {
430
398
  var existing = existingKeys[i];
431
399
  var required = requiredKeys[i];
432
400
  if (!existing || existing.field !== required.field || existing.direction !== required.direction || existing.includeMissing !== required.includeMissing) return false;
433
401
  }
434
-
435
402
  var existingFields = new Set(existingKeys.map(key => key.field));
436
403
  return requiredKeys.slice(candidate.accessKeyCount).every(key => existingFields.has(key.field));
437
404
  }
405
+
438
406
  /**
439
407
  * Normalizes an exemption list into a Set of lower-case resource names.
440
408
  * Accepts an array, a Set, or a comma-separated string (the env var form).
@@ -442,26 +410,22 @@ function indexSatisfiesCoveringDefinition(index, candidate) {
442
410
  * @param {string|string[]|Set<string>} value
443
411
  * @returns {Set<string>}
444
412
  */
445
-
446
-
447
413
  function parseResourceList(value) {
448
414
  var names = new Set();
449
415
  var items = [];
450
-
451
416
  if (Array.isArray(value) || value instanceof Set) {
452
417
  items = [...value];
453
418
  } else if (typeof value === "string") {
454
419
  items = value.split(",");
455
420
  }
456
-
457
421
  for (var item of items) {
458
422
  if (typeof item !== "string") continue;
459
423
  var name = item.trim().replace(/^`+|`+$/g, "").trim().toLowerCase();
460
424
  if (name) names.add(name);
461
425
  }
462
-
463
426
  return names;
464
427
  }
428
+
465
429
  /**
466
430
  * Extracts the resource names a statement touches, lower-cased:
467
431
  * - the last path segment of every FROM / JOIN keyspace, unquoted, so
@@ -472,34 +436,100 @@ function parseResourceList(value) {
472
436
  * @param {string} query
473
437
  * @returns {Set<string>}
474
438
  */
475
-
476
-
477
439
  function extractStatementResources(query) {
478
440
  var names = new Set();
479
441
  if (typeof query !== "string") return names;
480
442
  var identifier = "(?:`(?:[^`]|``)+`|[A-Za-z_][\\w$-]*)";
481
443
  var keyspacePattern = new RegExp("\\b(?:FROM|JOIN)\\s+(".concat(identifier, "(?:\\s*[.:]\\s*").concat(identifier, ")*)"), "gi");
482
444
  var segmentPattern = new RegExp(identifier, "g");
483
-
484
445
  for (var match of query.matchAll(keyspacePattern)) {
485
446
  var segments = match[1].match(segmentPattern) || [];
486
447
  var last = (segments[segments.length - 1] || "").trim();
487
448
  var name = last.startsWith("`") ? last.slice(1, -1).replace(/``/g, "`") : last;
488
449
  if (name) names.add(name.toLowerCase());
489
450
  }
490
-
491
451
  var typePattern = /(?:`type`|\btype)\s*=\s*'((?:[^']|'')*)'/gi;
492
-
493
452
  for (var _match of query.matchAll(typePattern)) {
494
453
  var _name = _match[1].replace(/''/g, "'").trim();
495
-
496
454
  if (_name) names.add(_name.toLowerCase());
497
455
  }
498
-
499
456
  return names;
500
457
  }
501
458
 
459
+ // Names the application's index manager owns. A statement whose plan already
460
+ // scans one of these is being served by a deliberate index, so the slow-query
461
+ // path leaves it alone. `ix_base` is the per-collection fallback and does not
462
+ // count: a statement that only finds the fallback is exactly what to report.
463
+ var MANAGED_INDEX_NAME = /^ix_(sort|eq|agg|core|obs)_[0-9a-f]{6}_[0-9a-f]{4}$/;
464
+
465
+ // Replaces the primary index on every collection: usable by any statement
466
+ // because the leading key includes MISSING, and ordered for the default list
467
+ // sort (`created_at DESC`).
468
+ var BASE_INDEX = Object.freeze({
469
+ name: "ix_base",
470
+ keys: Object.freeze([Object.freeze({
471
+ expr: "`created_at`",
472
+ desc: true,
473
+ include_missing: true
474
+ }), Object.freeze({
475
+ expr: "`deleted`"
476
+ })])
477
+ });
478
+
479
+ /**
480
+ * The statement with its literals replaced by `?`, so one query shape is
481
+ * examined once however many values it runs with. IN lists collapse to `[?]`.
482
+ * @param {string} query
483
+ * @returns {string}
484
+ */
485
+ function statementShape(query) {
486
+ return String(query !== null && query !== void 0 ? query : "").replace(/'(?:[^'\\]|\\.|'')*'/g, "?").replace(/"(?:[^"\\]|\\.)*"/g, "?").replace(/\b\d+(?:\.\d+)?\b/g, "?").replace(/\[\s*\?(?:\s*,\s*\?)*\s*\]/g, "[?]").replace(/\s+/g, " ").trim();
487
+ }
488
+
489
+ /**
490
+ * Names of the indexes an EXPLAIN plan scans.
491
+ * @param {object} plan
492
+ * @returns {string[]}
493
+ */
494
+ function planIndexNames(plan) {
495
+ var names = new Set();
496
+ var visit = node => {
497
+ if (!node || typeof node !== "object") return;
498
+ if (Array.isArray(node)) {
499
+ node.forEach(visit);
500
+ return;
501
+ }
502
+ if (/Scan/.test(String(node["#operator"] || "")) && typeof node.index === "string") {
503
+ names.add(node.index);
504
+ }
505
+ Object.values(node).forEach(visit);
506
+ };
507
+ visit(plan);
508
+ return [...names];
509
+ }
510
+ function usesManagedIndex(plan) {
511
+ return planIndexNames(plan).some(name => MANAGED_INDEX_NAME.test(name));
512
+ }
513
+
514
+ /**
515
+ * Renders one index key. Accepts a ready string or `{ expr, desc, include_missing }`.
516
+ * @param {string|{expr: string, desc?: boolean, include_missing?: boolean}} key
517
+ * @returns {string}
518
+ */
519
+ function renderIndexKey(key) {
520
+ if (typeof key === "string") return key;
521
+ if (!key || typeof key.expr !== "string" || !key.expr.trim()) {
522
+ throw new TypeError("An index key needs an expression");
523
+ }
524
+ return "".concat(key.expr).concat(key.desc ? " DESC" : "").concat(key.include_missing ? " INCLUDE MISSING" : "");
525
+ }
502
526
  module.exports = {
527
+ MANAGED_INDEX_NAME,
528
+ BASE_INDEX,
529
+ statementShape,
530
+ planIndexNames,
531
+ usesManagedIndex,
532
+ renderIndexKey,
503
533
  buildCoveringIndex,
504
534
  buildCoveringIndexDefinition,
505
535
  indexSatisfiesCoveringDefinition,