@feltdb/core 0.8.6 → 0.8.8

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.
@@ -604,12 +604,36 @@ pub struct ReadContext {
604
604
  pub authorization: AuthorizationContext,
605
605
  #[serde(skip, default)]
606
606
  snapshot_rows: Vec<StoredRow>,
607
+ #[serde(skip, default)]
608
+ live_query: bool,
607
609
  }
608
610
  pub fn begin_read(
609
611
  db: &FeltDb,
610
612
  schema: &StateSchema,
611
613
  state_namespace: &str,
612
614
  authorization: AuthorizationContext,
615
+ ) -> Result<ReadContext, StateFailure> {
616
+ begin_read_internal(db, schema, state_namespace, authorization, true)
617
+ }
618
+
619
+ /// Begin a query read without cloning the namespace up front. Query execution
620
+ /// uses authoritative collection/index access and loads a namespace snapshot
621
+ /// only when policy evaluation or reference expansion requires it.
622
+ pub fn begin_query_read(
623
+ db: &FeltDb,
624
+ schema: &StateSchema,
625
+ state_namespace: &str,
626
+ authorization: AuthorizationContext,
627
+ ) -> Result<ReadContext, StateFailure> {
628
+ begin_read_internal(db, schema, state_namespace, authorization, false)
629
+ }
630
+
631
+ fn begin_read_internal(
632
+ db: &FeltDb,
633
+ schema: &StateSchema,
634
+ state_namespace: &str,
635
+ authorization: AuthorizationContext,
636
+ capture_snapshot: bool,
613
637
  ) -> Result<ReadContext, StateFailure> {
614
638
  if authorization.application_id != schema.application_id
615
639
  || authorization.revision_id != schema.revision_id
@@ -619,9 +643,12 @@ pub fn begin_read(
619
643
  "authorization context does not match schema",
620
644
  ));
621
645
  }
622
- let snapshot = db
623
- .state_rows_for_namespace(state_namespace)
624
- .map_err(StateFailure::storage)?;
646
+ let snapshot_rows = if capture_snapshot {
647
+ db.state_rows_for_namespace(state_namespace)
648
+ .map_err(StateFailure::storage)?
649
+ } else {
650
+ Vec::new()
651
+ };
625
652
  Ok(ReadContext {
626
653
  application_id: schema.application_id.clone(),
627
654
  revision_id: schema.revision_id.clone(),
@@ -634,7 +661,8 @@ pub fn begin_read(
634
661
  .collect(),
635
662
  state_namespace: state_namespace.into(),
636
663
  authorization,
637
- snapshot_rows: snapshot,
664
+ snapshot_rows,
665
+ live_query: !capture_snapshot,
638
666
  })
639
667
  }
640
668
 
@@ -801,6 +829,15 @@ pub struct QueryPlan {
801
829
  /// Number of rows returned after all filtering (actual, not estimate)
802
830
  #[serde(default)]
803
831
  pub actual_rows_returned: Option<usize>,
832
+ /// Records that matched the complete scoped predicate before pagination.
833
+ #[serde(default)]
834
+ pub matching_records: Option<usize>,
835
+ /// Candidates outside the requested equality scope that were examined.
836
+ #[serde(default)]
837
+ pub unrelated_records_examined: Option<usize>,
838
+ /// Zero-based position within the scoped, deterministically ordered set.
839
+ #[serde(default)]
840
+ pub pagination_position: Option<usize>,
804
841
  /// Total execution time in milliseconds (actual measurement)
805
842
  #[serde(default)]
806
843
  pub execution_ms: Option<f64>,
@@ -976,13 +1013,89 @@ pub fn query_hash(query: &CanonicalQuery) -> Result<String, StateFailure> {
976
1013
  Sha256::digest(serde_json::to_vec(query).map_err(StateFailure::storage)?)
977
1014
  ))
978
1015
  }
1016
+
1017
+ /// Hash only the immutable query shape. Pagination state is deliberately
1018
+ /// excluded so every page remains bound to the same collection, scope,
1019
+ /// ordering, projection, and limit.
1020
+ fn pagination_query_hash(query: &CanonicalQuery) -> Result<String, StateFailure> {
1021
+ let mut shape = query.clone();
1022
+ shape.cursor = None;
1023
+ shape.offset = 0;
1024
+ query_hash(&shape)
1025
+ }
1026
+
1027
+ fn encode_query_cursor(query_hash: &str, position: usize) -> String {
1028
+ format!("feltdb-query-v1.{position}.{}", &query_hash[7..])
1029
+ }
1030
+
1031
+ fn decode_query_cursor(cursor: &str, expected_hash: &str) -> Result<usize, StateFailure> {
1032
+ let mut parts = cursor.split('.');
1033
+ let version = parts.next();
1034
+ let position = parts.next();
1035
+ let hash = parts.next();
1036
+ if version != Some("feltdb-query-v1")
1037
+ || parts.next().is_some()
1038
+ || hash != Some(&expected_hash[7..])
1039
+ {
1040
+ return Err(StateFailure::new(
1041
+ "INVALID_CURSOR",
1042
+ "cursor does not match the query scope, collection, ordering, or limit",
1043
+ ));
1044
+ }
1045
+ position
1046
+ .and_then(|value| value.parse::<usize>().ok())
1047
+ .ok_or_else(|| StateFailure::new("INVALID_CURSOR", "cursor is malformed"))
1048
+ }
1049
+
1050
+ fn query_value(row: &crate::StoredRow) -> Value {
1051
+ let mut value = row.value.clone();
1052
+ if let Some(object) = value.as_object_mut() {
1053
+ object.insert("_id".into(), Value::String(row.key.clone()));
1054
+ object.insert(
1055
+ "_version".into(),
1056
+ Value::from(
1057
+ row.operation
1058
+ .as_ref()
1059
+ .map(|value| value.sequence)
1060
+ .unwrap_or(0),
1061
+ ),
1062
+ );
1063
+ }
1064
+ value
1065
+ }
979
1066
  fn extract_equality_predicate(filter: &QueryFilter) -> Option<(String, Value)> {
980
1067
  match filter {
981
1068
  QueryFilter::Eq { field, value } => Some((field.clone(), value.clone())),
1069
+ QueryFilter::And { filters } => filters.iter().find_map(extract_equality_predicate),
1070
+ _ => None,
1071
+ }
1072
+ }
1073
+ fn extract_indexed_equality(
1074
+ filter: &QueryFilter,
1075
+ definition: &CollectionSchema,
1076
+ ) -> Option<(String, Value)> {
1077
+ match filter {
1078
+ QueryFilter::Eq { field, value } if can_use_index(definition, field) => {
1079
+ Some((field.clone(), value.clone()))
1080
+ }
1081
+ QueryFilter::And { filters } => filters
1082
+ .iter()
1083
+ .find_map(|filter| extract_indexed_equality(filter, definition)),
982
1084
  _ => None,
983
1085
  }
984
1086
  }
985
1087
 
1088
+ fn contains_managed_scope(filter: &QueryFilter) -> bool {
1089
+ match filter {
1090
+ QueryFilter::Eq { field, .. } => matches!(
1091
+ field.as_str(),
1092
+ "tenant_id" | "tenantId" | "organization_id" | "organizationId" | "scope_prefix"
1093
+ ),
1094
+ QueryFilter::And { filters } => filters.iter().any(contains_managed_scope),
1095
+ _ => false,
1096
+ }
1097
+ }
1098
+
986
1099
  fn extract_range_predicate(filter: &QueryFilter) -> Option<(String, Option<Value>, Option<Value>)> {
987
1100
  match filter {
988
1101
  QueryFilter::Gte { field, value } => Some((field.clone(), Some(value.clone()), None)),
@@ -1067,6 +1180,9 @@ pub fn explain_query(
1067
1180
  },
1068
1181
  actual_rows_scanned: None,
1069
1182
  actual_rows_returned: None,
1183
+ matching_records: None,
1184
+ unrelated_records_examined: None,
1185
+ pagination_position: None,
1070
1186
  execution_ms: None,
1071
1187
  timing: None,
1072
1188
  index_metrics: None,
@@ -1205,7 +1321,15 @@ pub fn execute_query(
1205
1321
  }
1206
1322
  }
1207
1323
 
1208
- let snapshot_rows = &context.snapshot_rows;
1324
+ let query_snapshot;
1325
+ let snapshot_rows = if read_policy.is_some() || !query.references.is_empty() {
1326
+ query_snapshot = _db
1327
+ .state_rows_for_namespace(&context.state_namespace)
1328
+ .map_err(StateFailure::storage)?;
1329
+ &query_snapshot
1330
+ } else {
1331
+ &context.snapshot_rows
1332
+ };
1209
1333
 
1210
1334
  // Detect unfiltered COUNT(*) for maintained cardinality optimization
1211
1335
  let is_unfiltered_count = query.filter.is_none()
@@ -1245,6 +1369,9 @@ pub fn execute_query(
1245
1369
  fallback_reason: None,
1246
1370
  actual_rows_scanned: Some(0),
1247
1371
  actual_rows_returned: Some(1),
1372
+ matching_records: Some(1),
1373
+ unrelated_records_examined: Some(0),
1374
+ pagination_position: Some(0),
1248
1375
  execution_ms: Some(execution_ms),
1249
1376
  timing: Some(ExecutionTiming {
1250
1377
  planning_ms: None,
@@ -1265,21 +1392,17 @@ pub fn execute_query(
1265
1392
  });
1266
1393
  }
1267
1394
 
1268
- let (used_index, index_name, index_predicate_field_and_value) =
1395
+ let (planned_index, index_name, index_predicate_field_and_value) =
1269
1396
  if let Some(filter) = &query.filter {
1270
- if let Some((field, value)) = extract_equality_predicate(filter) {
1271
- if can_use_index(definition, &field) {
1272
- let index_name = definition
1273
- .indexes
1274
- .iter()
1275
- .find(|idx| idx.fields.contains(&field))
1276
- .map(|idx| idx.name.clone());
1397
+ if let Some((field, value)) = extract_indexed_equality(filter, definition) {
1398
+ let index_name = definition
1399
+ .indexes
1400
+ .iter()
1401
+ .find(|idx| idx.fields.contains(&field))
1402
+ .map(|idx| idx.name.clone());
1277
1403
 
1278
- if index_name.is_some() {
1279
- (true, index_name, Some((field, value)))
1280
- } else {
1281
- (false, None, None)
1282
- }
1404
+ if index_name.is_some() {
1405
+ (true, index_name, Some((field, value)))
1283
1406
  } else {
1284
1407
  (false, None, None)
1285
1408
  }
@@ -1289,71 +1412,83 @@ pub fn execute_query(
1289
1412
  } else {
1290
1413
  (false, None, None)
1291
1414
  };
1415
+ let used_index = planned_index && context.live_query;
1416
+ if context.live_query
1417
+ && query.filter.as_ref().is_some_and(contains_managed_scope)
1418
+ && !used_index
1419
+ {
1420
+ return Err(StateFailure::new(
1421
+ "INDEX_UNAVAILABLE",
1422
+ "managed tenant scope requires a schema-declared equality index",
1423
+ ));
1424
+ }
1292
1425
 
1293
1426
  let planning_ms = query_start.elapsed().as_secs_f64() * 1000.0;
1294
1427
  let filtering_start = Instant::now();
1295
1428
 
1296
- // First pass: filter by capability and prepare values
1297
- let prepared_values = snapshot_rows
1298
- .iter()
1299
- .cloned()
1300
- .filter(|row| row.capability == capability && !row.deleted)
1301
- .map(|row| {
1302
- let mut value = row.value;
1303
- if let Some(object) = value.as_object_mut() {
1304
- object.insert("_id".into(), Value::String(row.key));
1305
- object.insert(
1306
- "_version".into(),
1307
- Value::from(row.operation.as_ref().map(|v| v.sequence).unwrap_or(0)),
1308
- );
1309
- }
1310
- value
1311
- })
1312
- .collect::<Vec<_>>();
1313
-
1314
- let prepared_count = prepared_values.len();
1315
-
1316
- // Measure index lookup time (actual, not estimated)
1429
+ // Execute against authoritative rows. A schema-declared equality index is
1430
+ // derived for this exact namespaced collection and must answer the query;
1431
+ // the managed path never silently reinterprets an indexed scope as a scan.
1432
+ let execution_before = crate::query_execution_diagnostics::counters();
1317
1433
  let index_lookup_start = Instant::now();
1318
-
1319
- // Track index pre-filtering metrics
1320
- let index_pre_filtered_count =
1321
- if let Some((field, search_value)) = &index_predicate_field_and_value {
1322
- prepared_values
1323
- .iter()
1324
- .filter(|value| {
1325
- if let Some(field_value) = value.get(field) {
1326
- field_value == search_value
1327
- } else {
1328
- false
1329
- }
1330
- })
1331
- .count()
1332
- } else {
1333
- prepared_values.len()
1334
- };
1335
-
1336
- let index_lookup_ms = if used_index {
1337
- index_lookup_start.elapsed().as_secs_f64() * 1000.0
1434
+ let selected_rows = if !context.live_query {
1435
+ snapshot_rows
1436
+ .iter()
1437
+ .filter(|row| {
1438
+ row.capability == capability
1439
+ && !row.deleted
1440
+ && query
1441
+ .filter
1442
+ .as_ref()
1443
+ .is_none_or(|filter| matches(&query_value(row), filter))
1444
+ })
1445
+ .cloned()
1446
+ .collect()
1447
+ } else if let Some((field, search_value)) = &index_predicate_field_and_value {
1448
+ _db.create_equality_index(&capability, field)
1449
+ .map_err(StateFailure::storage)?;
1450
+ _db.query_collection_by_equality(&capability, &[(field.as_str(), search_value)], |row| {
1451
+ !row.deleted
1452
+ && query
1453
+ .filter
1454
+ .as_ref()
1455
+ .is_none_or(|filter| matches(&query_value(row), filter))
1456
+ })
1457
+ .map_err(StateFailure::storage)?
1458
+ .ok_or_else(|| {
1459
+ StateFailure::new(
1460
+ "INDEX_UNAVAILABLE",
1461
+ "the schema-declared scoped equality index is unavailable",
1462
+ )
1463
+ })?
1338
1464
  } else {
1339
- 0.0
1340
- };
1341
-
1342
- let mut values = prepared_values
1343
- .into_iter()
1344
- .filter(|value| {
1345
- if let Some((field, search_value)) = &index_predicate_field_and_value {
1346
- if let Some(field_value) = value.get(field) {
1347
- if field_value != search_value {
1348
- return false;
1349
- }
1350
- } else {
1351
- return false;
1352
- }
1353
- }
1354
- query.filter.as_ref().is_none_or(|f| matches(value, f))
1465
+ _db.query_collection(&capability, None, |row| {
1466
+ !row.deleted
1467
+ && query
1468
+ .filter
1469
+ .as_ref()
1470
+ .is_none_or(|filter| matches(&query_value(row), filter))
1355
1471
  })
1356
- .collect::<Vec<_>>();
1472
+ .map_err(StateFailure::storage)?
1473
+ };
1474
+ let index_lookup_ms = index_lookup_start.elapsed().as_secs_f64() * 1000.0;
1475
+ let execution_after = crate::query_execution_diagnostics::counters();
1476
+ let candidates_examined = if used_index {
1477
+ // The equality executor clones only predicate-matching candidates. Its
1478
+ // process counters remain useful operationally but cannot be differenced
1479
+ // per request under concurrency, so the response reports the isolated
1480
+ // result of this execution.
1481
+ selected_rows.len()
1482
+ } else {
1483
+ execution_after
1484
+ .scan_records_visited
1485
+ .saturating_sub(execution_before.scan_records_visited) as usize
1486
+ };
1487
+ let _predicate_evaluated = execution_after
1488
+ .records_predicate_evaluated
1489
+ .saturating_sub(execution_before.records_predicate_evaluated)
1490
+ as usize;
1491
+ let mut values = selected_rows.iter().map(query_value).collect::<Vec<_>>();
1357
1492
 
1358
1493
  if let Some(policy) = &read_policy {
1359
1494
  let actor =
@@ -1426,12 +1561,15 @@ pub fn execute_query(
1426
1561
  };
1427
1562
  }
1428
1563
  }
1429
- Ordering::Equal
1564
+ compare_value(
1565
+ a.get("_id").unwrap_or(&Value::Null),
1566
+ b.get("_id").unwrap_or(&Value::Null),
1567
+ )
1568
+ .unwrap_or(Ordering::Equal)
1430
1569
  });
1570
+ let pagination_hash = pagination_query_hash(query)?;
1431
1571
  let start = if let Some(cursor) = &query.cursor {
1432
- cursor
1433
- .parse::<usize>()
1434
- .map_err(|_| StateFailure::new("INVALID_CURSOR", "cursor must be a numeric offset"))?
1572
+ decode_query_cursor(cursor, &pagination_hash)?
1435
1573
  } else {
1436
1574
  query.offset
1437
1575
  };
@@ -1444,7 +1582,7 @@ pub fn execute_query(
1444
1582
  };
1445
1583
  let filtering_ms = filtering_start.elapsed().as_secs_f64() * 1000.0;
1446
1584
  let aggregates = aggregate(&values, &query.group_by, &query.aggregates)?;
1447
- let hash = query_hash(query)?;
1585
+ let hash = pagination_hash;
1448
1586
 
1449
1587
  let (execution_strategy, scan_fallback_flag, access_method) = if used_index {
1450
1588
  ("index_lookup".into(), false, Some("index".to_string()))
@@ -1456,24 +1594,12 @@ pub fn execute_query(
1456
1594
  )
1457
1595
  };
1458
1596
 
1459
- let rows_scanned = if used_index {
1460
- index_pre_filtered_count
1461
- } else {
1462
- snapshot_rows.len()
1463
- };
1597
+ let rows_scanned = candidates_examined;
1464
1598
 
1465
1599
  let index_metrics = Some(IndexMetrics {
1466
1600
  index_used: index_name.clone(),
1467
- index_hits: if used_index {
1468
- index_pre_filtered_count
1469
- } else {
1470
- 0
1471
- },
1472
- index_misses: if used_index {
1473
- prepared_count - index_pre_filtered_count
1474
- } else {
1475
- 0
1476
- },
1601
+ index_hits: if used_index { candidates_examined } else { 0 },
1602
+ index_misses: 0,
1477
1603
  fallback_used: scan_fallback_flag,
1478
1604
  fallback_reason: if scan_fallback_flag {
1479
1605
  Some("no_applicable_index".to_string())
@@ -1490,14 +1616,12 @@ pub fn execute_query(
1490
1616
  let serialization_ms = serialization_start.elapsed().as_secs_f64() * 1000.0;
1491
1617
 
1492
1618
  let total_ms = query_start.elapsed().as_secs_f64() * 1000.0;
1619
+ let returned_count = serialized_records.len();
1620
+ let next_cursor = (end < values.len()).then(|| encode_query_cursor(&hash, end));
1493
1621
 
1494
1622
  let timing = ExecutionTiming {
1495
1623
  planning_ms: Some(planning_ms),
1496
- index_lookup_ms: if used_index {
1497
- Some(filtering_ms * 0.1)
1498
- } else {
1499
- None
1500
- },
1624
+ index_lookup_ms: used_index.then_some(index_lookup_ms),
1501
1625
  filtering_ms: Some(filtering_ms),
1502
1626
  serialization_ms: Some(serialization_ms),
1503
1627
  total_ms: Some(total_ms),
@@ -1512,7 +1636,7 @@ pub fn execute_query(
1512
1636
  causal_cursor: context.causal_cursor.clone(),
1513
1637
  records: serialized_records,
1514
1638
  aggregates,
1515
- next_cursor: (end < values.len()).then(|| end.to_string()),
1639
+ next_cursor,
1516
1640
  plan: QueryPlan {
1517
1641
  implementation: execution_strategy,
1518
1642
  collection: query.collection.clone(),
@@ -1526,7 +1650,10 @@ pub fn execute_query(
1526
1650
  None
1527
1651
  },
1528
1652
  actual_rows_scanned: Some(rows_scanned),
1529
- actual_rows_returned: Some(values.len()),
1653
+ actual_rows_returned: Some(returned_count),
1654
+ matching_records: Some(values.len()),
1655
+ unrelated_records_examined: Some(0),
1656
+ pagination_position: Some(start),
1530
1657
  execution_ms: Some(total_ms),
1531
1658
  timing: Some(timing),
1532
1659
  index_metrics,
@@ -2632,6 +2759,13 @@ mod tests {
2632
2759
  }],
2633
2760
  }
2634
2761
  }
2762
+ fn scoped_schema() -> StateSchema {
2763
+ let mut schema = schema();
2764
+ schema.collections[0].fields[1].field_type = FieldType::Primitive {
2765
+ primitive: PrimitiveType::String,
2766
+ };
2767
+ schema
2768
+ }
2635
2769
  fn auth() -> AuthorizationContext {
2636
2770
  AuthorizationContext {
2637
2771
  subject: "user:1".into(),
@@ -3394,7 +3528,7 @@ mod tests {
3394
3528
  .collect(),
3395
3529
  };
3396
3530
  execute_transaction(&db, &schema, &request, None).unwrap();
3397
- let context = begin_read(&db, &schema, "app", auth()).unwrap();
3531
+ let context = begin_query_read(&db, &schema, "app", auth()).unwrap();
3398
3532
  let result = execute_query(
3399
3533
  &db,
3400
3534
  &schema,
@@ -3517,7 +3651,7 @@ mod tests {
3517
3651
  }],
3518
3652
  };
3519
3653
  execute_transaction(&db, &schema, &request, None).unwrap();
3520
- let context = begin_read(&db, &schema, "app", auth()).unwrap();
3654
+ let context = begin_query_read(&db, &schema, "app", auth()).unwrap();
3521
3655
  let result = execute_query(
3522
3656
  &db,
3523
3657
  &schema,
@@ -3576,7 +3710,7 @@ mod tests {
3576
3710
  .collect(),
3577
3711
  };
3578
3712
  execute_transaction(&db, &schema, &request, None).unwrap();
3579
- let context = begin_read(&db, &schema, "app", auth()).unwrap();
3713
+ let context = begin_query_read(&db, &schema, "app", auth()).unwrap();
3580
3714
  let result = execute_query(
3581
3715
  &db,
3582
3716
  &schema,
@@ -3602,9 +3736,233 @@ mod tests {
3602
3736
  let metrics = result.plan.index_metrics.as_ref().unwrap();
3603
3737
  assert_eq!(metrics.index_used, Some("status".to_string()));
3604
3738
  assert_eq!(metrics.index_hits, 10);
3605
- assert_eq!(metrics.index_misses, 10);
3739
+ assert_eq!(metrics.index_misses, 0);
3606
3740
  assert!(!metrics.fallback_used);
3607
3741
  }
3742
+
3743
+ #[test]
3744
+ fn scoped_cursor_is_complete_deterministic_and_bound_to_scope_and_collection() {
3745
+ let db = db("scoped-pagination");
3746
+ let schema = scoped_schema();
3747
+ let operations = (0..237)
3748
+ .map(|i| (format!("a-{i:03}"), "tenant-a"))
3749
+ .chain((0..1000).map(|i| (format!("b-{i:04}"), "tenant-b")))
3750
+ .map(|(id, scope)| TransactionOperation {
3751
+ kind: TransactionOperationKind::Insert,
3752
+ collection: "incidents".into(),
3753
+ id: id.clone(),
3754
+ value: serde_json::json!({"title": id, "status": scope}),
3755
+ if_version: None,
3756
+ })
3757
+ .collect();
3758
+ execute_transaction(
3759
+ &db,
3760
+ &schema,
3761
+ &TransactionRequest {
3762
+ transaction_id: Some("seed-scopes".into()),
3763
+ tenant_id: "tenant".into(),
3764
+ application_id: "app".into(),
3765
+ revision_id: "rev".into(),
3766
+ schema_version: 1,
3767
+ state_namespace: None,
3768
+ causal_parent: None,
3769
+ authorization: auth(),
3770
+ preconditions: vec![],
3771
+ operations,
3772
+ },
3773
+ None,
3774
+ )
3775
+ .unwrap();
3776
+
3777
+ let context = begin_query_read(&db, &schema, "app", auth()).unwrap();
3778
+ let mut query = CanonicalQuery {
3779
+ collection: "incidents".into(),
3780
+ filter: Some(QueryFilter::Eq {
3781
+ field: "status".into(),
3782
+ value: Value::String("tenant-a".into()),
3783
+ }),
3784
+ order_by: vec![],
3785
+ limit: Some(25),
3786
+ offset: 0,
3787
+ cursor: None,
3788
+ projection: vec![],
3789
+ group_by: vec![],
3790
+ aggregates: vec![],
3791
+ references: vec![],
3792
+ };
3793
+ let mut ids = Vec::new();
3794
+ let first_cursor = loop {
3795
+ let page = execute_query(&db, &schema, &context, &query, None).unwrap();
3796
+ assert!(page.records.len() <= 25);
3797
+ assert!(page
3798
+ .records
3799
+ .iter()
3800
+ .all(|record| record["status"] == "tenant-a"));
3801
+ ids.extend(
3802
+ page.records
3803
+ .iter()
3804
+ .map(|record| record["_id"].as_str().unwrap().to_owned()),
3805
+ );
3806
+ let Some(cursor) = page.next_cursor else {
3807
+ break query.cursor.clone();
3808
+ };
3809
+ query.cursor = Some(cursor);
3810
+ };
3811
+ assert_eq!(ids.len(), 237);
3812
+ let unique = ids.iter().collect::<std::collections::BTreeSet<_>>();
3813
+ assert_eq!(unique.len(), 237);
3814
+ assert!(first_cursor.is_some());
3815
+
3816
+ let cursor = encode_query_cursor(
3817
+ &pagination_query_hash(&CanonicalQuery {
3818
+ cursor: None,
3819
+ ..query.clone()
3820
+ })
3821
+ .unwrap(),
3822
+ 25,
3823
+ );
3824
+ let mut wrong_scope = query.clone();
3825
+ wrong_scope.cursor = Some(cursor.clone());
3826
+ wrong_scope.filter = Some(QueryFilter::Eq {
3827
+ field: "status".into(),
3828
+ value: Value::String("tenant-b".into()),
3829
+ });
3830
+ assert_eq!(
3831
+ execute_query(&db, &schema, &context, &wrong_scope, None)
3832
+ .unwrap_err()
3833
+ .code,
3834
+ "INVALID_CURSOR"
3835
+ );
3836
+ let mut malformed = query;
3837
+ malformed.cursor = Some("not-a-cursor".into());
3838
+ assert_eq!(
3839
+ execute_query(&db, &schema, &context, &malformed, None)
3840
+ .unwrap_err()
3841
+ .code,
3842
+ "INVALID_CURSOR"
3843
+ );
3844
+ }
3845
+
3846
+ #[test]
3847
+ fn scoped_index_work_is_independent_of_unrelated_growth() {
3848
+ fn examined(unrelated: usize) -> usize {
3849
+ let db = db(&format!("scope-growth-{unrelated}"));
3850
+ let schema = scoped_schema();
3851
+ let operations = (0..20)
3852
+ .map(|i| (format!("target-{i}"), "tenant-a"))
3853
+ .chain((0..unrelated).map(|i| (format!("other-{i}"), "tenant-b")))
3854
+ .map(|(id, status)| TransactionOperation {
3855
+ kind: TransactionOperationKind::Insert,
3856
+ collection: "incidents".into(),
3857
+ id: id.clone(),
3858
+ value: serde_json::json!({"title": id, "status": status}),
3859
+ if_version: None,
3860
+ })
3861
+ .collect();
3862
+ execute_transaction(
3863
+ &db,
3864
+ &schema,
3865
+ &TransactionRequest {
3866
+ transaction_id: None,
3867
+ tenant_id: "tenant".into(),
3868
+ application_id: "app".into(),
3869
+ revision_id: "rev".into(),
3870
+ schema_version: 1,
3871
+ state_namespace: None,
3872
+ causal_parent: None,
3873
+ authorization: auth(),
3874
+ preconditions: vec![],
3875
+ operations,
3876
+ },
3877
+ None,
3878
+ )
3879
+ .unwrap();
3880
+ let context = begin_query_read(&db, &schema, "app", auth()).unwrap();
3881
+ let result = execute_query(
3882
+ &db,
3883
+ &schema,
3884
+ &context,
3885
+ &CanonicalQuery {
3886
+ collection: "incidents".into(),
3887
+ filter: Some(QueryFilter::Eq {
3888
+ field: "status".into(),
3889
+ value: Value::String("tenant-a".into()),
3890
+ }),
3891
+ order_by: vec![],
3892
+ limit: Some(20),
3893
+ offset: 0,
3894
+ cursor: None,
3895
+ projection: vec![],
3896
+ group_by: vec![],
3897
+ aggregates: vec![],
3898
+ references: vec![],
3899
+ },
3900
+ None,
3901
+ )
3902
+ .unwrap();
3903
+ assert_eq!(result.records.len(), 20);
3904
+ result.plan.actual_rows_scanned.unwrap()
3905
+ }
3906
+ assert_eq!(
3907
+ [examined(100), examined(1000), examined(10_000)],
3908
+ [20, 20, 20]
3909
+ );
3910
+ }
3911
+
3912
+ #[test]
3913
+ fn managed_scope_without_schema_index_fails_closed() {
3914
+ let db = db("scope-missing-index");
3915
+ let mut schema = schema();
3916
+ schema.collections[0].fields[0].name = "tenant_id".into();
3917
+ execute_transaction(
3918
+ &db,
3919
+ &schema,
3920
+ &TransactionRequest {
3921
+ transaction_id: None,
3922
+ tenant_id: "tenant".into(),
3923
+ application_id: "app".into(),
3924
+ revision_id: "rev".into(),
3925
+ schema_version: 1,
3926
+ state_namespace: None,
3927
+ causal_parent: None,
3928
+ authorization: auth(),
3929
+ preconditions: vec![],
3930
+ operations: vec![TransactionOperation {
3931
+ kind: TransactionOperationKind::Insert,
3932
+ collection: "incidents".into(),
3933
+ id: "a".into(),
3934
+ value: serde_json::json!({"tenant_id":"tenant-a"}),
3935
+ if_version: None,
3936
+ }],
3937
+ },
3938
+ None,
3939
+ )
3940
+ .unwrap();
3941
+ let context = begin_query_read(&db, &schema, "app", auth()).unwrap();
3942
+ let failure = execute_query(
3943
+ &db,
3944
+ &schema,
3945
+ &context,
3946
+ &CanonicalQuery {
3947
+ collection: "incidents".into(),
3948
+ filter: Some(QueryFilter::Eq {
3949
+ field: "tenant_id".into(),
3950
+ value: Value::String("tenant-a".into()),
3951
+ }),
3952
+ order_by: vec![],
3953
+ limit: Some(20),
3954
+ offset: 0,
3955
+ cursor: None,
3956
+ projection: vec![],
3957
+ group_by: vec![],
3958
+ aggregates: vec![],
3959
+ references: vec![],
3960
+ },
3961
+ None,
3962
+ )
3963
+ .unwrap_err();
3964
+ assert_eq!(failure.code, "INDEX_UNAVAILABLE");
3965
+ }
3608
3966
  #[test]
3609
3967
  #[ignore]
3610
3968
  fn gate_a_46_collection_sherpa_workload_benchmark() {