@feltdb/core 0.8.7 → 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.
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +26 -1
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +465 -107
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +3 -3
- package/dist/create/server-source/crates/feltdb-wasm/src/lib.rs +1 -1
- package/dist/http-db.d.ts +11 -0
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +11 -3
- package/dist/studio-app/assets/{index-Bbos1m2U.js → index-ByHX4xDq.js} +1 -1
- package/dist/studio-app/index.html +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
623
|
-
.state_rows_for_namespace(state_namespace)
|
|
624
|
-
|
|
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
|
|
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
|
|
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 (
|
|
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)) =
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
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
|
-
|
|
1279
|
-
|
|
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
|
-
//
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
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
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
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
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
-
.
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
|
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(
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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,
|
|
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() {
|