spice-couchbase 2.0.64 → 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.
@@ -1,22 +1,19 @@
1
1
  "use strict";
2
2
 
3
3
  var assert = require("assert");
4
-
5
- var {
6
- buildCoveringIndex,
7
- extractWhereFields,
8
- extractSelectFields,
9
- extractGroupByFields,
10
- extractOrderByFields,
11
- generateHash,
12
- parseResourceList,
13
- extractStatementResources
14
- } = require("./utility"); // Test utilities
15
-
16
-
4
+ var _require = require("./utility"),
5
+ buildCoveringIndex = _require.buildCoveringIndex,
6
+ extractWhereFields = _require.extractWhereFields,
7
+ extractSelectFields = _require.extractSelectFields,
8
+ extractGroupByFields = _require.extractGroupByFields,
9
+ extractOrderByFields = _require.extractOrderByFields,
10
+ generateHash = _require.generateHash,
11
+ parseResourceList = _require.parseResourceList,
12
+ extractStatementResources = _require.extractStatementResources;
13
+
14
+ // Test utilities
17
15
  var passed = 0;
18
16
  var failed = 0;
19
-
20
17
  function test(name, fn) {
21
18
  try {
22
19
  fn();
@@ -28,17 +25,16 @@ function test(name, fn) {
28
25
  failed++;
29
26
  }
30
27
  }
31
-
32
28
  function arraysEqual(a, b) {
33
29
  if (a.length !== b.length) return false;
34
30
  var sortedA = [...a].sort();
35
31
  var sortedB = [...b].sort();
36
32
  return sortedA.every((val, i) => val === sortedB[i]);
37
- } // ============================================
33
+ }
34
+
35
+ // ============================================
38
36
  // Tests for generateHash
39
37
  // ============================================
40
-
41
-
42
38
  console.log("\n--- generateHash tests ---");
43
39
  test("generates 8 character hash", () => {
44
40
  var hash = generateHash("test query");
@@ -53,10 +49,11 @@ test("generates different hash for different input", () => {
53
49
  var hash1 = generateHash("SELECT * FROM users");
54
50
  var hash2 = generateHash("SELECT * FROM orders");
55
51
  assert.notStrictEqual(hash1, hash2);
56
- }); // ============================================
52
+ });
53
+
54
+ // ============================================
57
55
  // Tests for extractWhereFields
58
56
  // ============================================
59
-
60
57
  console.log("\n--- extractWhereFields tests ---");
61
58
  test("extracts simple equality field", () => {
62
59
  var query = "SELECT name FROM users WHERE status = 'active'";
@@ -225,10 +222,11 @@ test("handles complex ANY with deleted pattern", () => {
225
222
  assert.ok(fields.includes("deleted"));
226
223
  assert.ok(fields.includes("status"));
227
224
  assert.ok(fields.includes("tags"));
228
- }); // ============================================
225
+ });
226
+
227
+ // ============================================
229
228
  // Tests for extractSelectFields
230
229
  // ============================================
231
-
232
230
  console.log("\n--- extractSelectFields tests ---");
233
231
  test("extracts simple field", () => {
234
232
  var query = "SELECT name FROM users";
@@ -294,8 +292,8 @@ test("handles multiple aggregate functions", () => {
294
292
  });
295
293
  test("handles nested function calls", () => {
296
294
  var query = "SELECT LOWER(TRIM(name)) AS clean_name FROM users";
297
- var fields = extractSelectFields(query); // Should extract 'name' from nested functions - currently extracts last match
298
-
295
+ var fields = extractSelectFields(query);
296
+ // Should extract 'name' from nested functions - currently extracts last match
299
297
  assert.ok(fields.length >= 0); // At minimum should not crash
300
298
  });
301
299
  test("handles query with JOIN", () => {
@@ -324,10 +322,11 @@ test("handles backtick collection prefix", () => {
324
322
  var fields = extractSelectFields(query);
325
323
  assert.ok(fields.includes("first_name"));
326
324
  assert.ok(fields.includes("last_name"));
327
- }); // ============================================
325
+ });
326
+
327
+ // ============================================
328
328
  // Tests for extractGroupByFields
329
329
  // ============================================
330
-
331
330
  console.log("\n--- extractGroupByFields tests ---");
332
331
  test("extracts simple GROUP BY field", () => {
333
332
  var query = "SELECT status, COUNT(*) FROM users GROUP BY status";
@@ -378,10 +377,11 @@ test("handles GROUP BY with HAVING and ORDER BY", () => {
378
377
  var query = "SELECT status, COUNT(*) FROM users GROUP BY status HAVING COUNT(*) > 5 ORDER BY status";
379
378
  var fields = extractGroupByFields(query);
380
379
  assert.ok(arraysEqual(fields, ["status"]));
381
- }); // ============================================
380
+ });
381
+
382
+ // ============================================
382
383
  // Tests for extractOrderByFields
383
384
  // ============================================
384
-
385
385
  console.log("\n--- extractOrderByFields tests ---");
386
386
  test("extracts simple ORDER BY field", () => {
387
387
  var query = "SELECT name FROM users ORDER BY created_at";
@@ -442,10 +442,11 @@ test("handles mixed ASC and DESC", () => {
442
442
  var query = "SELECT name FROM users ORDER BY last_name ASC, created_at DESC";
443
443
  var fields = extractOrderByFields(query);
444
444
  assert.ok(arraysEqual(fields, ["last_name", "created_at"]));
445
- }); // ============================================
445
+ });
446
+
447
+ // ============================================
446
448
  // Tests for buildCoveringIndex
447
449
  // ============================================
448
-
449
450
  console.log("\n--- buildCoveringIndex tests ---");
450
451
  var defaultOptions = {
451
452
  bucket: "saturn",
@@ -479,16 +480,18 @@ test("WHERE fields come before SELECT fields", () => {
479
480
  });
480
481
  test("deduplicates fields in WHERE and SELECT", () => {
481
482
  var query = "SELECT status, name FROM users WHERE status = 'active'";
482
- var result = buildCoveringIndex(query, defaultOptions); // status should only appear once
483
+ var result = buildCoveringIndex(query, defaultOptions);
483
484
 
485
+ // status should only appear once
484
486
  var firstStatus = result.indexOf("`status`");
485
487
  var lastStatus = result.lastIndexOf("`status`");
486
488
  assert.strictEqual(firstStatus, lastStatus);
487
489
  });
488
490
  test("excludes deleted from WHERE fields (added separately)", () => {
489
491
  var query = "SELECT name FROM users WHERE (deleted = false OR deleted IS MISSING) AND status = 'active'";
490
- var result = buildCoveringIndex(query, defaultOptions); // deleted should only appear once (as INCLUDE MISSING)
492
+ var result = buildCoveringIndex(query, defaultOptions);
491
493
 
494
+ // deleted should only appear once (as INCLUDE MISSING)
492
495
  var matches = result.match(/`deleted`/g);
493
496
  assert.strictEqual(matches.length, 1);
494
497
  assert.ok(result.includes("`deleted` INCLUDE MISSING"));
@@ -526,8 +529,9 @@ test("generates unique index name from query hash", () => {
526
529
  var query1 = "SELECT name FROM users WHERE status = 'active'";
527
530
  var query2 = "SELECT email FROM users WHERE type = 'admin'";
528
531
  var result1 = buildCoveringIndex(query1, defaultOptions);
529
- var result2 = buildCoveringIndex(query2, defaultOptions); // Extract index names
532
+ var result2 = buildCoveringIndex(query2, defaultOptions);
530
533
 
534
+ // Extract index names
531
535
  var name1 = result1.match(/CREATE INDEX `(adv_covered_\w+)`/)[1];
532
536
  var name2 = result2.match(/CREATE INDEX `(adv_covered_\w+)`/)[1];
533
537
  assert.notStrictEqual(name1, name2);
@@ -545,8 +549,8 @@ test("handles complex query with multiple conditions", () => {
545
549
  });
546
550
  test("returns null when only deleted field in query", () => {
547
551
  var query = "SELECT deleted FROM users WHERE deleted = false";
548
- var result = buildCoveringIndex(query, defaultOptions); // Should return null because after filtering out 'deleted', there are no fields left
549
-
552
+ var result = buildCoveringIndex(query, defaultOptions);
553
+ // Should return null because after filtering out 'deleted', there are no fields left
550
554
  assert.strictEqual(result, null);
551
555
  });
552
556
  test("handles query with type field (legacy pattern)", () => {
@@ -593,11 +597,13 @@ test("same index name for different queries with same fields", () => {
593
597
  var query1 = "SELECT name, email FROM users WHERE status = 'active'";
594
598
  var query2 = "SELECT email, name FROM users WHERE status = 'pending'";
595
599
  var result1 = buildCoveringIndex(query1, defaultOptions);
596
- var result2 = buildCoveringIndex(query2, defaultOptions); // Extract index names
600
+ var result2 = buildCoveringIndex(query2, defaultOptions);
597
601
 
602
+ // Extract index names
598
603
  var name1 = result1.match(/CREATE INDEX `(adv_covered_\w+)`/)[1];
599
- var name2 = result2.match(/CREATE INDEX `(adv_covered_\w+)`/)[1]; // Should have the same index name since they have the same fields
604
+ var name2 = result2.match(/CREATE INDEX `(adv_covered_\w+)`/)[1];
600
605
 
606
+ // Should have the same index name since they have the same fields
601
607
  assert.strictEqual(name1, name2);
602
608
  });
603
609
  test("different index name for different ORDER BY direction", () => {
@@ -622,17 +628,20 @@ test("different index name for queries with different fields", () => {
622
628
  var query1 = "SELECT name FROM users WHERE status = 'active'";
623
629
  var query2 = "SELECT email FROM users WHERE type = 'admin'";
624
630
  var result1 = buildCoveringIndex(query1, defaultOptions);
625
- var result2 = buildCoveringIndex(query2, defaultOptions); // Extract index names
631
+ var result2 = buildCoveringIndex(query2, defaultOptions);
626
632
 
633
+ // Extract index names
627
634
  var name1 = result1.match(/CREATE INDEX `(adv_covered_\w+)`/)[1];
628
- var name2 = result2.match(/CREATE INDEX `(adv_covered_\w+)`/)[1]; // Should have different index names since they have different fields
635
+ var name2 = result2.match(/CREATE INDEX `(adv_covered_\w+)`/)[1];
629
636
 
637
+ // Should have different index names since they have different fields
630
638
  assert.notStrictEqual(name1, name2);
631
639
  });
632
640
  test("index includes all fields needed for covering", () => {
633
641
  var query = "SELECT \n first_name, \n middle_name, \n last_name, \n status, \n constituency, \n division, \n description \n FROM subject \n WHERE (deleted = false OR deleted IS MISSING) \n AND created_at > '2024-01-01'";
634
- var result = buildCoveringIndex(query, defaultOptions); // Verify structure matches the working example pattern
642
+ var result = buildCoveringIndex(query, defaultOptions);
635
643
 
644
+ // Verify structure matches the working example pattern
636
645
  assert.ok(result.startsWith("CREATE INDEX `adv_covered_"));
637
646
  assert.ok(result.includes("`deleted` INCLUDE MISSING"));
638
647
  assert.ok(result.includes("`created_at`"));
@@ -688,16 +697,18 @@ test("ORDER BY fields come after WHERE fields", () => {
688
697
  var result = buildCoveringIndex(query, defaultOptions);
689
698
  var statusIndex = result.indexOf("`status`");
690
699
  var createdAtIndex = result.indexOf("`created_at`");
691
- var emailIndex = result.indexOf("`email`"); // WHERE field (status) should come before ORDER BY field (created_at)
692
-
693
- assert.ok(statusIndex < createdAtIndex); // ORDER BY field (created_at) should come before SELECT field (email)
700
+ var emailIndex = result.indexOf("`email`");
694
701
 
702
+ // WHERE field (status) should come before ORDER BY field (created_at)
703
+ assert.ok(statusIndex < createdAtIndex);
704
+ // ORDER BY field (created_at) should come before SELECT field (email)
695
705
  assert.ok(createdAtIndex < emailIndex);
696
706
  });
697
707
  test("deduplicates ORDER BY field if already in WHERE", () => {
698
708
  var query = "SELECT name FROM users WHERE status = 'active' AND created_at > '2024-01-01' ORDER BY created_at DESC";
699
- var result = buildCoveringIndex(query, defaultOptions); // created_at should only appear once
709
+ var result = buildCoveringIndex(query, defaultOptions);
700
710
 
711
+ // created_at should only appear once
701
712
  var firstCreatedAt = result.indexOf("`created_at`");
702
713
  var lastCreatedAt = result.lastIndexOf("`created_at`");
703
714
  assert.strictEqual(firstCreatedAt, lastCreatedAt);
@@ -705,8 +716,9 @@ test("deduplicates ORDER BY field if already in WHERE", () => {
705
716
  });
706
717
  test("deduplicates ORDER BY field if already in SELECT", () => {
707
718
  var query = "SELECT name, created_at FROM users WHERE status = 'active' ORDER BY created_at DESC";
708
- var result = buildCoveringIndex(query, defaultOptions); // created_at should only appear once (in ORDER BY position, before SELECT fields)
719
+ var result = buildCoveringIndex(query, defaultOptions);
709
720
 
721
+ // created_at should only appear once (in ORDER BY position, before SELECT fields)
710
722
  var matches = result.match(/`created_at`/g);
711
723
  assert.strictEqual(matches.length, 1);
712
724
  assert.ok(result.includes("`created_at` DESC"));
@@ -715,8 +727,9 @@ test("handles multiple ORDER BY fields", () => {
715
727
  var query = "SELECT email FROM users WHERE status = 'active' ORDER BY last_name ASC, first_name ASC";
716
728
  var result = buildCoveringIndex(query, defaultOptions);
717
729
  assert.ok(result.includes("`last_name`"));
718
- assert.ok(result.includes("`first_name`")); // ORDER BY fields should come before SELECT fields
730
+ assert.ok(result.includes("`first_name`"));
719
731
 
732
+ // ORDER BY fields should come before SELECT fields
720
733
  var lastNameIndex = result.indexOf("`last_name`");
721
734
  var firstNameIndex = result.indexOf("`first_name`");
722
735
  var emailIndex = result.indexOf("`email`");
@@ -739,8 +752,9 @@ test("handles realistic query with ORDER BY", () => {
739
752
  assert.ok(result.includes("`created_at` DESC"));
740
753
  assert.ok(result.includes("`first_name`"));
741
754
  assert.ok(result.includes("`last_name`"));
742
- assert.ok(result.includes("`email`")); // Verify order: status (WHERE) -> created_at (ORDER BY) -> SELECT fields
755
+ assert.ok(result.includes("`email`"));
743
756
 
757
+ // Verify order: status (WHERE) -> created_at (ORDER BY) -> SELECT fields
744
758
  var statusIndex = result.indexOf("`status`");
745
759
  var createdAtIndex = result.indexOf("`created_at`");
746
760
  var firstNameIndex = result.indexOf("`first_name`");
@@ -760,25 +774,29 @@ test("GROUP BY fields come after WHERE fields", () => {
760
774
  var query = "SELECT COUNT(name) FROM users WHERE type = 'user' GROUP BY status";
761
775
  var result = buildCoveringIndex(query, defaultOptions);
762
776
  var typeIndex = result.indexOf("`type`");
763
- var statusIndex = result.indexOf("`status`"); // WHERE field (type) should come before GROUP BY field (status)
777
+ var statusIndex = result.indexOf("`status`");
764
778
 
779
+ // WHERE field (type) should come before GROUP BY field (status)
765
780
  assert.ok(typeIndex < statusIndex);
766
781
  });
767
782
  test("GROUP BY fields come before ORDER BY fields", () => {
768
783
  var query = "SELECT COUNT(name) FROM users WHERE type = 'user' GROUP BY status ORDER BY status";
769
784
  var result = buildCoveringIndex(query, defaultOptions);
770
785
  var typeIndex = result.indexOf("`type`");
771
- var statusIndex = result.indexOf("`status`"); // status is in both GROUP BY and ORDER BY, should only appear once
786
+ var statusIndex = result.indexOf("`status`");
772
787
 
788
+ // status is in both GROUP BY and ORDER BY, should only appear once
773
789
  var matches = result.match(/`status`/g);
774
- assert.strictEqual(matches.length, 1); // WHERE field (type) should come before GROUP BY field (status)
790
+ assert.strictEqual(matches.length, 1);
775
791
 
792
+ // WHERE field (type) should come before GROUP BY field (status)
776
793
  assert.ok(typeIndex < statusIndex);
777
794
  });
778
795
  test("deduplicates GROUP BY field if already in WHERE", () => {
779
796
  var query = "SELECT COUNT(name) FROM users WHERE status = 'active' GROUP BY status";
780
- var result = buildCoveringIndex(query, defaultOptions); // status should only appear once
797
+ var result = buildCoveringIndex(query, defaultOptions);
781
798
 
799
+ // status should only appear once
782
800
  var matches = result.match(/`status`/g);
783
801
  assert.strictEqual(matches.length, 1);
784
802
  });
@@ -805,8 +823,9 @@ test("handles realistic GROUP BY query", () => {
805
823
  assert.ok(result.includes("`deleted` INCLUDE MISSING"));
806
824
  assert.ok(result.includes("`type`"));
807
825
  assert.ok(result.includes("`status`"));
808
- assert.ok(result.includes("`first_name`")); // Verify order: type (WHERE) -> status (GROUP BY) -> first_name (SELECT)
826
+ assert.ok(result.includes("`first_name`"));
809
827
 
828
+ // Verify order: type (WHERE) -> status (GROUP BY) -> first_name (SELECT)
810
829
  var typeIndex = result.indexOf("`type`");
811
830
  var statusIndex = result.indexOf("`status`");
812
831
  var firstNameIndex = result.indexOf("`first_name`");
@@ -819,17 +838,17 @@ test("field order: WHERE -> GROUP BY -> ORDER BY -> SELECT", () => {
819
838
  var typeIndex = result.indexOf("`type`");
820
839
  var statusIndex = result.indexOf("`status`");
821
840
  var createdAtIndex = result.indexOf("`created_at`");
822
- var emailIndex = result.indexOf("`email`"); // Verify correct order
841
+ var emailIndex = result.indexOf("`email`");
823
842
 
843
+ // Verify correct order
824
844
  assert.ok(typeIndex < statusIndex); // WHERE before GROUP BY
825
-
826
845
  assert.ok(statusIndex < createdAtIndex); // GROUP BY before ORDER BY
827
-
828
846
  assert.ok(createdAtIndex < emailIndex); // ORDER BY before SELECT
829
- }); // ============================================
847
+ });
848
+
849
+ // ============================================
830
850
  // Tests for parseResourceList / extractStatementResources
831
851
  // ============================================
832
-
833
852
  console.log("\n--- parseResourceList tests ---");
834
853
  test("parses a comma-separated string, trimming, unquoting and lower-casing", () => {
835
854
  var result = parseResourceList(" Users, `Audit_Log` ,, sessions ");
@@ -872,10 +891,11 @@ test("does not treat META() arguments, subqueries or column names as keyspaces",
872
891
  test("handles non-string input", () => {
873
892
  assert.strictEqual(extractStatementResources(undefined).size, 0);
874
893
  assert.strictEqual(extractStatementResources(null).size, 0);
875
- }); // ============================================
894
+ });
895
+
896
+ // ============================================
876
897
  // Summary
877
898
  // ============================================
878
-
879
899
  console.log("\n========================================");
880
900
  console.log("Tests: ".concat(passed + failed, " | Passed: ").concat(passed, " | Failed: ").concat(failed));
881
901
  console.log("========================================\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spice-couchbase",
3
- "version": "2.0.64",
3
+ "version": "2.0.65",
4
4
  "description": "",
5
5
  "main": "build/index.js",
6
6
  "scripts": {
@@ -0,0 +1,232 @@
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 {
9
+ statementShape,
10
+ planIndexNames,
11
+ usesManagedIndex,
12
+ renderIndexKey,
13
+ BASE_INDEX,
14
+ } = require("./utility");
15
+
16
+ // Same isolation as index.auto-optimize.test.js: a fresh module per load, so
17
+ // the seen-set, cooldown and handler never leak between tests.
18
+ function loadDriver(durationMs, env = {}) {
19
+ let tick = BigInt(0);
20
+ const module = { exports: {} };
21
+ vm.runInNewContext(fs.readFileSync(path.join(__dirname, "index.js"), "utf8"), {
22
+ require,
23
+ module,
24
+ console,
25
+ process: {
26
+ env,
27
+ hrtime: {
28
+ bigint: () => {
29
+ const current = tick;
30
+ tick += BigInt(durationMs) * BigInt(1000000);
31
+ return current;
32
+ },
33
+ },
34
+ },
35
+ });
36
+ return module.exports;
37
+ }
38
+
39
+ function recordingCluster(rowsFor = () => []) {
40
+ const calls = [];
41
+ return {
42
+ calls,
43
+ async query(statement, options) {
44
+ calls.push({ statement, options });
45
+ return { rows: rowsFor(statement), meta: {} };
46
+ },
47
+ };
48
+ }
49
+
50
+ function createDatabase(Database, cluster) {
51
+ const db = Object.create(Database.prototype);
52
+ db.bucket = "saturn";
53
+ db.scope_name = "test";
54
+ db.collection_name = "users";
55
+ db.db_config = { bucket: "saturn" };
56
+ db.auto_optimize = true;
57
+ db.cluster = cluster;
58
+ db.scope = cluster;
59
+ return db;
60
+ }
61
+
62
+ const KEYSPACE = "default:`saturn`.`test`.`users`";
63
+
64
+ test("statement shapes ignore literal values and IN list lengths", () => {
65
+ assert.equal(
66
+ statementShape("SELECT * FROM `users` WHERE email = 'a@b.c' AND age > 30 AND id IN ['x', 'y']"),
67
+ statementShape("SELECT * FROM `users` WHERE email = \"d@e.f\" AND age > 7 AND id IN ['z']"),
68
+ );
69
+ });
70
+
71
+ test("managed indexes are recognised in a plan, the base fallback is not", () => {
72
+ const plan = (index) => ({ "#operator": "Sequence", "~children": [{ "#operator": "IndexScan3", index }] });
73
+ assert.deepEqual(planIndexNames(plan("ix_sort_1a2b3c_9f0e")), ["ix_sort_1a2b3c_9f0e"]);
74
+ assert.equal(usesManagedIndex(plan("ix_sort_1a2b3c_9f0e")), true);
75
+ assert.equal(usesManagedIndex(plan("ix_base")), false);
76
+ assert.equal(usesManagedIndex(plan("adv_covered_1a3952f8")), false);
77
+ });
78
+
79
+ test("index keys render direction and INCLUDE MISSING", () => {
80
+ assert.deepEqual(BASE_INDEX.keys.map(renderIndexKey), ["`created_at` DESC INCLUDE MISSING", "`deleted`"]);
81
+ assert.equal(renderIndexKey("`name`"), "`name`");
82
+ assert.throws(() => renderIndexKey({}), TypeError);
83
+ });
84
+
85
+ test("createIndex is idempotent, deferred by default and fully qualified", async () => {
86
+ const cluster = recordingCluster();
87
+ const db = createDatabase(loadDriver(0), cluster);
88
+ await db.createIndex({
89
+ name: "ix_sort_1a2b3c_9f0e",
90
+ keys: [{ expr: "`priority`", desc: true, include_missing: true }, { expr: "IFMISSINGORNULL(`due_at`, {})" }],
91
+ where: "IFMISSING(`deleted`, false) = false",
92
+ num_replica: 1,
93
+ });
94
+ assert.equal(
95
+ cluster.calls[0].statement,
96
+ `CREATE INDEX IF NOT EXISTS \`ix_sort_1a2b3c_9f0e\` ON ${KEYSPACE}` +
97
+ "(`priority` DESC INCLUDE MISSING, IFMISSINGORNULL(`due_at`, {})) " +
98
+ 'WHERE IFMISSING(`deleted`, false) = false WITH {"defer_build":true,"num_replica":1}',
99
+ );
100
+ });
101
+
102
+ test("build, drop and primary drop target the collection", async () => {
103
+ const cluster = recordingCluster();
104
+ const db = createDatabase(loadDriver(0), cluster);
105
+ await db.buildIndexes(["ix_a", "ix_b", "ix_a"]);
106
+ await db.dropIndex("ix_a");
107
+ await db.dropPrimaryIndex();
108
+ assert.deepEqual(cluster.calls.map(({ statement }) => statement), [
109
+ `BUILD INDEX ON ${KEYSPACE}(\`ix_a\`, \`ix_b\`)`,
110
+ `DROP INDEX IF EXISTS \`ix_a\` ON ${KEYSPACE}`,
111
+ `DROP PRIMARY INDEX IF EXISTS ON ${KEYSPACE}`,
112
+ ]);
113
+ assert.equal(await db.buildIndexes([]), null);
114
+ });
115
+
116
+ test("a partial index inventory is an error, not an empty list", async () => {
117
+ const Database = loadDriver(0);
118
+ const db = createDatabase(Database, {
119
+ async query() {
120
+ return { rows: [], meta: { warnings: [{ msg: "partial" }] } };
121
+ },
122
+ });
123
+ await assert.rejects(() => db.listIndexes(), /index inventory/);
124
+ });
125
+
126
+ test("the client context id reaches the SDK options", async () => {
127
+ const cluster = recordingCluster();
128
+ const db = createDatabase(loadDriver(0), cluster);
129
+ await db.query("SELECT 1", "cluster", { client_context_id: "scheduler.due" });
130
+ assert.equal(cluster.calls[0].options.clientContextId, "scheduler.due");
131
+ });
132
+
133
+ function scanTimeout() {
134
+ const error = new Error("Index scan timed out");
135
+ error.cause = { first_error_code: 12015, first_error_message: "Index scan timed out" };
136
+ return error;
137
+ }
138
+
139
+ test("an expired scan_wait reruns without waiting only when the caller allows it", async () => {
140
+ const Database = loadDriver(0);
141
+ const attempts = [];
142
+ const cluster = {
143
+ async query(statement, options) {
144
+ attempts.push(options);
145
+ if (attempts.length === 1) throw scanTimeout();
146
+ return { rows: [{ ok: true }] };
147
+ },
148
+ };
149
+ const db = createDatabase(Database, cluster);
150
+ const response = await db.query("SELECT 1", "cluster", {
151
+ scan_consistency: "request_plus",
152
+ scan_wait: 2000,
153
+ scan_wait_fallback: true,
154
+ });
155
+ assert.deepEqual(response.rows, [{ ok: true }]);
156
+ assert.equal(attempts.length, 2);
157
+ assert.equal(attempts[1].scanWait, undefined);
158
+ assert.notEqual(attempts[1].scanConsistency, attempts[0].scanConsistency);
159
+ });
160
+
161
+ test("without the fallback an expired scan_wait is an error", async () => {
162
+ const Database = loadDriver(0);
163
+ const db = createDatabase(Database, {
164
+ async query() {
165
+ throw scanTimeout();
166
+ },
167
+ });
168
+ await assert.rejects(
169
+ () => db.query("SELECT 1", "cluster", { scan_consistency: "request_plus", scan_wait: 2000 }),
170
+ /Index scan timed out/,
171
+ );
172
+ });
173
+
174
+ test("with a handler installed, slow statements are handed off once per shape and never indexed here", async () => {
175
+ const Database = loadDriver(5000);
176
+ const cluster = recordingCluster((statement) =>
177
+ statement.startsWith("EXPLAIN ") ? [{ "#operator": "IndexScan3", index: "ix_base" }] : [],
178
+ );
179
+ const db = createDatabase(Database, cluster);
180
+ const reports = [];
181
+ Database.setSlowStatementHandler(async (report) => reports.push(report));
182
+ let legacy = 0;
183
+ db.optimize = async () => { legacy += 1; };
184
+
185
+ await db.query("SELECT name FROM users WHERE email = 'a@b.c'");
186
+ await db.query("SELECT name FROM users WHERE email = 'd@e.f'");
187
+ await new Promise((resolve) => setImmediate(resolve));
188
+
189
+ assert.equal(reports.length, 1);
190
+ assert.equal(reports[0].shape, "SELECT name FROM users WHERE email = ?");
191
+ assert.equal(reports[0].collection, "users");
192
+ assert.equal(legacy, 0);
193
+ assert.equal(cluster.calls.some(({ statement }) => statement.startsWith("CREATE ")), false);
194
+ });
195
+
196
+ test("a slow statement already served by a managed index is not reported", async () => {
197
+ const Database = loadDriver(5000);
198
+ const cluster = recordingCluster((statement) =>
199
+ statement.startsWith("EXPLAIN ") ? [{ "#operator": "IndexScan3", index: "ix_sort_1a2b3c_9f0e" }] : [],
200
+ );
201
+ const db = createDatabase(Database, cluster);
202
+ const reports = [];
203
+ Database.setSlowStatementHandler(async (report) => reports.push(report));
204
+ await db.query("SELECT name FROM users ORDER BY priority DESC");
205
+ await new Promise((resolve) => setImmediate(resolve));
206
+ assert.equal(reports.length, 0);
207
+ });
208
+
209
+ test("without a handler the legacy covering-index path still runs", async () => {
210
+ const Database = loadDriver(5000);
211
+ const db = createDatabase(Database, recordingCluster());
212
+ let legacy = 0;
213
+ db.optimize = async () => { legacy += 1; };
214
+ await db.query("SELECT name FROM users WHERE status = 'active'");
215
+ assert.equal(legacy, 1);
216
+ });
217
+
218
+ test("a new collection gets the base index instead of a primary index when asked", async () => {
219
+ const Database = loadDriver(0);
220
+ const cluster = recordingCluster();
221
+ const db = createDatabase(Database, cluster);
222
+ db.db_config.primary_index = false;
223
+ db.checkGlobelConnectionObject = () => false;
224
+ db.collectionExists = async () => false;
225
+ db.createCollection = async () => {};
226
+ db.wait = async () => {};
227
+ let primary = 0;
228
+ db.createPrimaryIndex = async () => { primary += 1; };
229
+ await db.upsertCollection();
230
+ assert.equal(primary, 0);
231
+ assert.match(cluster.calls[0].statement, /^CREATE INDEX IF NOT EXISTS `ix_base` ON .*\(`created_at` DESC INCLUDE MISSING, `deleted`\) WITH \{"defer_build":false\}$/);
232
+ });