create-feltdb 0.11.4 → 0.11.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/package-versions.js +1 -1
- package/dist/server-source/crates/feltdb/src/lib.rs +10 -1
- package/dist/server-source/crates/feltdb/src/semantic_decision.rs +124 -12
- package/dist/server-source/crates/feltdb/src/semantic_query.rs +599 -0
- package/dist/server-source/crates/feltdb/src/state_contract.rs +222 -0
- package/dist/server-source/crates/feltdb/tests/pr7_self_authorization_proof.rs +1 -0
- package/dist/server-source/crates/feltdb/tests/saas_invitation_lifecycle.rs +1 -0
- package/dist/server-source/crates/feltdb/tests/semantic_query.rs +767 -0
- package/dist/server-source/crates/feltdb/tests/semantic_query_contract.rs +363 -0
- package/dist/server-source/crates/feltdb-server/src/app_state.rs +5 -0
- package/dist/server-source/crates/feltdb-server/src/backup.rs +290 -7
- package/dist/server-source/crates/feltdb-server/src/main.rs +452 -15
- package/dist/server-source/crates/feltdb-wasm/test_wasm.db.lock +0 -0
- package/package.json +1 -1
|
@@ -75,7 +75,7 @@ use feltdb_server::{
|
|
|
75
75
|
authenticated_principal::AuthenticatedPrincipal,
|
|
76
76
|
authorization::{authorize, AuthorizationRequest, Subject},
|
|
77
77
|
backup::{
|
|
78
|
-
|
|
78
|
+
restore_bundle, select_bundle_at,
|
|
79
79
|
server_artifacts, verify_bundle,
|
|
80
80
|
},
|
|
81
81
|
causal::{
|
|
@@ -7303,6 +7303,7 @@ async fn run_runtime_query(
|
|
|
7303
7303
|
group_by: vec![],
|
|
7304
7304
|
aggregates: vec![],
|
|
7305
7305
|
references: vec![],
|
|
7306
|
+
semantic: None,
|
|
7306
7307
|
};
|
|
7307
7308
|
let context = feltdb::state_contract::begin_query_read(
|
|
7308
7309
|
&state.db,
|
|
@@ -7555,6 +7556,7 @@ fn state_contract_error(error: feltdb::state_contract::StateFailure) -> ApiError
|
|
|
7555
7556
|
"AUTHORIZATION_DENIED" => StatusCode::FORBIDDEN,
|
|
7556
7557
|
"PRECONDITION_FAILED" | "CONFLICT" | "IDEMPOTENCY_CONFLICT" => StatusCode::CONFLICT,
|
|
7557
7558
|
"UNKNOWN_COLLECTION" => StatusCode::NOT_FOUND,
|
|
7559
|
+
"SEMANTIC_UNSUPPORTED" => StatusCode::NOT_IMPLEMENTED,
|
|
7558
7560
|
"STORAGE_FAILURE" | "STATE_UNAVAILABLE" => StatusCode::SERVICE_UNAVAILABLE,
|
|
7559
7561
|
_ => StatusCode::UNPROCESSABLE_ENTITY,
|
|
7560
7562
|
};
|
|
@@ -8527,6 +8529,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8527
8529
|
if manage_backup()? {
|
|
8528
8530
|
return Ok(());
|
|
8529
8531
|
}
|
|
8532
|
+
if manage_doctor()? {
|
|
8533
|
+
return Ok(());
|
|
8534
|
+
}
|
|
8530
8535
|
if manage_application_runtime()? {
|
|
8531
8536
|
return Ok(());
|
|
8532
8537
|
}
|
|
@@ -8675,6 +8680,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8675
8680
|
mesh: Arc::new(std::sync::Mutex::new(mesh_store)),
|
|
8676
8681
|
readiness_probe: Arc::new(readiness_probe),
|
|
8677
8682
|
bounded_query_cursors: Arc::new(std::sync::Mutex::new(HashMap::new())),
|
|
8683
|
+
semantic_runtime: configured_semantic_runtime()?,
|
|
8678
8684
|
};
|
|
8679
8685
|
start_peer_sessions(&state, &config)?;
|
|
8680
8686
|
start_membership_recovery(&state);
|
|
@@ -9986,8 +9992,16 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
|
|
|
9986
9992
|
match outcome {
|
|
9987
9993
|
Ok(Ok(Ok(health))) => {
|
|
9988
9994
|
let ready = health.is_nominal();
|
|
9995
|
+
let recovering = matches!(
|
|
9996
|
+
state.cluster.proposal().map(|value| value.phase),
|
|
9997
|
+
Some(ProposalPhase::Preparing | ProposalPhase::Prepared)
|
|
9998
|
+
);
|
|
9989
9999
|
let body = Json(json!({
|
|
9990
10000
|
"ready": ready,
|
|
10001
|
+
// The explicit health model. A running server has already
|
|
10002
|
+
// refused a corrupt or incompatible log at open, so `corrupt`
|
|
10003
|
+
// is reported offline by `feltdb-server doctor`, never here.
|
|
10004
|
+
"state": if !ready { "degraded" } else if recovering { "recovering" } else { "healthy" },
|
|
9991
10005
|
"storage": health.storage.label(),
|
|
9992
10006
|
"durable_format": health.durable_format.to_string(),
|
|
9993
10007
|
"probe_ms": waited_ms,
|
|
@@ -10003,6 +10017,7 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
|
|
|
10003
10017
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
10004
10018
|
Json(json!({
|
|
10005
10019
|
"ready": false,
|
|
10020
|
+
"state": "unavailable",
|
|
10006
10021
|
"error": error.to_string(),
|
|
10007
10022
|
"code": "STORAGE_UNAVAILABLE",
|
|
10008
10023
|
"probe_ms": waited_ms,
|
|
@@ -10013,6 +10028,7 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
|
|
|
10013
10028
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
10014
10029
|
Json(json!({
|
|
10015
10030
|
"ready": false,
|
|
10031
|
+
"state": "unavailable",
|
|
10016
10032
|
"error": error.to_string(),
|
|
10017
10033
|
"code": "STORAGE_PROBE_FAILED",
|
|
10018
10034
|
"probe_ms": waited_ms,
|
|
@@ -10023,6 +10039,7 @@ async fn health_ready(State(state): State<AppState>) -> axum::response::Response
|
|
|
10023
10039
|
StatusCode::SERVICE_UNAVAILABLE,
|
|
10024
10040
|
Json(json!({
|
|
10025
10041
|
"ready": false,
|
|
10042
|
+
"state": "unavailable",
|
|
10026
10043
|
"error": "storage probe exceeded the server deadline",
|
|
10027
10044
|
"code": "STORAGE_STALLED",
|
|
10028
10045
|
"probe_ms": waited_ms,
|
|
@@ -10367,7 +10384,6 @@ fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10367
10384
|
if arguments.get(1).map(String::as_str) != Some("backup") {
|
|
10368
10385
|
return Ok(false);
|
|
10369
10386
|
}
|
|
10370
|
-
let command = arguments.get(2).map(String::as_str).unwrap_or("");
|
|
10371
10387
|
let option = |name: &str| {
|
|
10372
10388
|
arguments
|
|
10373
10389
|
.iter()
|
|
@@ -10375,27 +10391,105 @@ fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10375
10391
|
.and_then(|index| arguments.get(index + 1))
|
|
10376
10392
|
.cloned()
|
|
10377
10393
|
};
|
|
10394
|
+
let json_output = arguments.iter().any(|value| value == "--json");
|
|
10395
|
+
// `backup <path>` is `backup create --output <path>`; `backup verify <path>`
|
|
10396
|
+
// is `backup verify --archive <path>`. The flag forms remain supported.
|
|
10397
|
+
let positional = |from: usize| {
|
|
10398
|
+
let mut index = from;
|
|
10399
|
+
while let Some(value) = arguments.get(index) {
|
|
10400
|
+
if value == "--json" {
|
|
10401
|
+
index += 1;
|
|
10402
|
+
} else if value.starts_with("--") {
|
|
10403
|
+
index += 2;
|
|
10404
|
+
} else {
|
|
10405
|
+
return Some(value.clone());
|
|
10406
|
+
}
|
|
10407
|
+
}
|
|
10408
|
+
None
|
|
10409
|
+
};
|
|
10410
|
+
let (command, target) = match arguments.get(2).map(String::as_str) {
|
|
10411
|
+
Some(command @ ("create" | "verify" | "restore")) => (command, positional(3)),
|
|
10412
|
+
Some(value) if !value.starts_with("--") => ("create", Some(value.to_string())),
|
|
10413
|
+
_ if option("--output").is_some() && option("--archive").is_none() => ("create", None),
|
|
10414
|
+
_ => ("", None),
|
|
10415
|
+
};
|
|
10416
|
+
let usage = "usage: feltdb-server backup [create] <output> | backup verify <archive> | backup restore --archive <archive> --output <directory> [--at-ms timestamp] (options: --data path --keys path --audit path --json)";
|
|
10417
|
+
let started = Instant::now();
|
|
10418
|
+
let emit = |report: &feltdb_server::backup::BackupReport, human: String| {
|
|
10419
|
+
if json_output {
|
|
10420
|
+
println!("{}", serde_json::to_string_pretty(report).unwrap_or_default());
|
|
10421
|
+
} else if report.status == "ok" {
|
|
10422
|
+
println!("{human}");
|
|
10423
|
+
} else {
|
|
10424
|
+
eprintln!(
|
|
10425
|
+
"Backup {} failed: {} ({})",
|
|
10426
|
+
report.operation,
|
|
10427
|
+
report.path,
|
|
10428
|
+
report.error.as_deref().unwrap_or("unknown error")
|
|
10429
|
+
);
|
|
10430
|
+
}
|
|
10431
|
+
};
|
|
10378
10432
|
match command {
|
|
10379
10433
|
"create" => {
|
|
10380
10434
|
let data = PathBuf::from(option("--data").unwrap_or_else(|| "./data/feltdb.log".into()));
|
|
10381
10435
|
let keys = PathBuf::from(option("--keys").unwrap_or_else(|| "./data/api-keys.json".into()));
|
|
10382
10436
|
let audit = PathBuf::from(option("--audit").unwrap_or_else(|| data.with_extension("audit.log").to_string_lossy().into_owned()));
|
|
10383
|
-
let output =
|
|
10384
|
-
let
|
|
10385
|
-
|
|
10437
|
+
let output = option("--output").or(target).ok_or("backup create requires an output path")?;
|
|
10438
|
+
let output = feltdb_server::backup::normalize_path(std::path::Path::new(&output))?;
|
|
10439
|
+
let data = feltdb_server::backup::normalize_path(&data)?;
|
|
10440
|
+
if !data.exists() {
|
|
10441
|
+
return Err(format!("backup source does not exist: {}", data.display()).into());
|
|
10442
|
+
}
|
|
10443
|
+
let source = feltdb_server::backup::BackupSource {
|
|
10444
|
+
data: Some(data.display().to_string()),
|
|
10445
|
+
state_sha256: None,
|
|
10446
|
+
server_version: Some(env!("CARGO_PKG_VERSION").into()),
|
|
10447
|
+
durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
|
|
10448
|
+
};
|
|
10449
|
+
feltdb_server::backup::create_bundle_with_source(&output, &server_artifacts(&data, &keys, &audit), &[], source)?;
|
|
10450
|
+
// A backup is reported as successful only after it verifies
|
|
10451
|
+
// independently of the code path that wrote it.
|
|
10452
|
+
let mut report = feltdb_server::backup::verification_report(&output, started, "create");
|
|
10453
|
+
if report.status == "ok" {
|
|
10454
|
+
if let Err(error) = verify_restorable(&output) {
|
|
10455
|
+
report.status = "failed".into();
|
|
10456
|
+
report.verification = "failed".into();
|
|
10457
|
+
report.error = Some(error.to_string());
|
|
10458
|
+
}
|
|
10459
|
+
}
|
|
10460
|
+
let human = format!("Backup created: {} (id {}, format {}, {} files, {} bytes, verified, {} ms)", report.path, &report.backup_id[..report.backup_id.len().min(12)], report.format, report.files, report.bytes_written, report.duration_ms);
|
|
10461
|
+
emit(&report, human);
|
|
10462
|
+
if report.status != "ok" {
|
|
10463
|
+
std::process::exit(1);
|
|
10464
|
+
}
|
|
10386
10465
|
}
|
|
10387
10466
|
"verify" => {
|
|
10388
|
-
let archive =
|
|
10389
|
-
let
|
|
10390
|
-
|
|
10467
|
+
let archive = option("--archive").or(target).ok_or("backup verify requires an archive path")?;
|
|
10468
|
+
let mut report = feltdb_server::backup::verification_report(std::path::Path::new(&archive), started, "verify");
|
|
10469
|
+
let mut restorable = None;
|
|
10470
|
+
if report.status == "ok" {
|
|
10471
|
+
match verify_restorable(std::path::Path::new(&report.path)) {
|
|
10472
|
+
Ok(detail) => restorable = Some(detail),
|
|
10473
|
+
Err(error) => {
|
|
10474
|
+
report.status = "failed".into();
|
|
10475
|
+
report.verification = "failed".into();
|
|
10476
|
+
report.error = Some(error.to_string());
|
|
10477
|
+
}
|
|
10478
|
+
}
|
|
10479
|
+
}
|
|
10480
|
+
let human = format!("Backup verified: {} (id {}, format {}, {} files, {} bytes{})", report.path, &report.backup_id[..report.backup_id.len().min(12)], report.format, report.files, report.bytes_written, restorable.map(|detail| format!(", {detail}")).unwrap_or_default());
|
|
10481
|
+
emit(&report, human);
|
|
10482
|
+
if report.status != "ok" {
|
|
10483
|
+
std::process::exit(1);
|
|
10484
|
+
}
|
|
10391
10485
|
}
|
|
10392
10486
|
"restore" => {
|
|
10393
|
-
let archive = PathBuf::from(option("--archive").ok_or("backup restore requires --archive")?);
|
|
10487
|
+
let archive = PathBuf::from(option("--archive").or(target).ok_or("backup restore requires --archive")?);
|
|
10394
10488
|
let archive = match option("--at-ms") {
|
|
10395
10489
|
Some(value) => select_bundle_at(&archive, value.parse().map_err(|_| "backup restore --at-ms must be an unsigned millisecond timestamp")?)?,
|
|
10396
10490
|
None => archive,
|
|
10397
10491
|
};
|
|
10398
|
-
let output =
|
|
10492
|
+
let output = feltdb_server::backup::normalize_path(std::path::Path::new(&option("--output").ok_or("backup restore requires --output")?))?;
|
|
10399
10493
|
let manifest = restore_bundle(&archive, &output)?;
|
|
10400
10494
|
let snapshot_path = output.join("files/database.snapshot.json");
|
|
10401
10495
|
if snapshot_path.exists() {
|
|
@@ -10403,13 +10497,199 @@ fn manage_backup() -> Result<bool, Box<dyn std::error::Error>> {
|
|
|
10403
10497
|
let database = FeltDb::open(output.join("files/state.log"))?;
|
|
10404
10498
|
database.install_snapshot(snapshot)?;
|
|
10405
10499
|
}
|
|
10406
|
-
|
|
10500
|
+
let mut report = feltdb_server::backup::verification_report(&archive, started, "restore");
|
|
10501
|
+
report.path = output.display().to_string();
|
|
10502
|
+
emit(&report, format!("Backup restored: {} (format {}, {} files)", output.display(), manifest.format, manifest.entries.len()));
|
|
10503
|
+
if report.status != "ok" {
|
|
10504
|
+
std::process::exit(1);
|
|
10505
|
+
}
|
|
10407
10506
|
}
|
|
10408
|
-
_ => return Err(
|
|
10507
|
+
_ => return Err(usage.into()),
|
|
10409
10508
|
}
|
|
10410
10509
|
Ok(true)
|
|
10411
10510
|
}
|
|
10412
10511
|
|
|
10512
|
+
/// `feltdb-server doctor` — what an operator needs to know, offline.
|
|
10513
|
+
///
|
|
10514
|
+
/// Answers, for a data path: is the database healthy; is the durable log
|
|
10515
|
+
/// valid; what state/version is it in; is recovery pending; can a backup be
|
|
10516
|
+
/// created; can that backup be restored; which server and format versions are
|
|
10517
|
+
/// active. It never opens the live files: the state is bundled exactly as
|
|
10518
|
+
/// `backup create` would, and every probe — open, read, write — runs against a
|
|
10519
|
+
/// private restored copy. `--backup <archive>` additionally verifies an
|
|
10520
|
+
/// existing bundle.
|
|
10521
|
+
///
|
|
10522
|
+
/// The health state is one of `healthy`, `recovering`, `degraded`,
|
|
10523
|
+
/// `unavailable`, or `corrupt`, and `healthy` requires every probe to pass:
|
|
10524
|
+
/// a readable durable log in a supported format, a clean replay, a working
|
|
10525
|
+
/// read, a working write, and a restorable backup.
|
|
10526
|
+
fn manage_doctor() -> Result<bool, Box<dyn std::error::Error>> {
|
|
10527
|
+
let arguments: Vec<String> = std::env::args().collect();
|
|
10528
|
+
if arguments.get(1).map(String::as_str) != Some("doctor") {
|
|
10529
|
+
return Ok(false);
|
|
10530
|
+
}
|
|
10531
|
+
let option = |name: &str| {
|
|
10532
|
+
arguments
|
|
10533
|
+
.iter()
|
|
10534
|
+
.position(|value| value == name)
|
|
10535
|
+
.and_then(|index| arguments.get(index + 1))
|
|
10536
|
+
.cloned()
|
|
10537
|
+
};
|
|
10538
|
+
let json_output = arguments.iter().any(|value| value == "--json");
|
|
10539
|
+
let data = feltdb_server::backup::normalize_path(&PathBuf::from(
|
|
10540
|
+
option("--data").unwrap_or_else(|| "./data/feltdb.log".into()),
|
|
10541
|
+
))?;
|
|
10542
|
+
let keys = PathBuf::from(option("--keys").unwrap_or_else(|| "./data/api-keys.json".into()));
|
|
10543
|
+
let audit = PathBuf::from(option("--audit").unwrap_or_else(|| data.with_extension("audit.log").to_string_lossy().into_owned()));
|
|
10544
|
+
|
|
10545
|
+
let mut checks: Vec<Value> = Vec::new();
|
|
10546
|
+
let mut check = |name: &str, ok: bool, detail: String| {
|
|
10547
|
+
checks.push(json!({ "name": name, "status": if ok { "PASS" } else { "FAIL" }, "detail": detail }));
|
|
10548
|
+
ok
|
|
10549
|
+
};
|
|
10550
|
+
let scratch = std::env::temp_dir().join(format!(
|
|
10551
|
+
"feltdb-doctor-{}-{}",
|
|
10552
|
+
std::process::id(),
|
|
10553
|
+
rand::random::<u64>()
|
|
10554
|
+
));
|
|
10555
|
+
let bundle = scratch.join("bundle");
|
|
10556
|
+
let restored = scratch.join("restored");
|
|
10557
|
+
let mut state = "healthy";
|
|
10558
|
+
let mut facts = json!({
|
|
10559
|
+
"data": data.display().to_string(),
|
|
10560
|
+
"server_version": env!("CARGO_PKG_VERSION"),
|
|
10561
|
+
"supported_durable_format": feltdb::DURABLE_FORMAT_VERSION,
|
|
10562
|
+
"backup_format": feltdb_server::backup::BACKUP_FORMAT,
|
|
10563
|
+
});
|
|
10564
|
+
|
|
10565
|
+
if !check("data present", data.exists(), data.display().to_string()) {
|
|
10566
|
+
state = "unavailable";
|
|
10567
|
+
} else {
|
|
10568
|
+
std::fs::create_dir_all(&scratch)?;
|
|
10569
|
+
let source = feltdb_server::backup::BackupSource {
|
|
10570
|
+
data: Some(data.display().to_string()),
|
|
10571
|
+
state_sha256: None,
|
|
10572
|
+
server_version: Some(env!("CARGO_PKG_VERSION").into()),
|
|
10573
|
+
durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
|
|
10574
|
+
};
|
|
10575
|
+
let bundled = feltdb_server::backup::create_bundle_with_source(&bundle, &server_artifacts(&data, &keys, &audit), &[], source);
|
|
10576
|
+
if !check("backup can be created", bundled.is_ok(), bundled.as_ref().map(|manifest| format!("{} files", manifest.entries.len())).unwrap_or_else(|error| error.to_string())) {
|
|
10577
|
+
state = "unavailable";
|
|
10578
|
+
} else {
|
|
10579
|
+
let restorable = restore_bundle(&bundle, &restored);
|
|
10580
|
+
check("backup can be restored", restorable.is_ok(), restorable.as_ref().map(|_| "restored to a private copy".to_string()).unwrap_or_else(|error| error.to_string()));
|
|
10581
|
+
match FeltDb::open(restored.join("files/state.log")) {
|
|
10582
|
+
Err(error) => {
|
|
10583
|
+
let corrupt = matches!(error, feltdb::FlowError::CorruptLogLine(_) | feltdb::FlowError::IncompatibleFormat(_));
|
|
10584
|
+
check("durable log valid", false, error.to_string());
|
|
10585
|
+
state = if corrupt { "corrupt" } else { "unavailable" };
|
|
10586
|
+
}
|
|
10587
|
+
Ok(database) => {
|
|
10588
|
+
let health = database.health();
|
|
10589
|
+
facts["durable_format"] = json!(health.durable_format.to_string());
|
|
10590
|
+
facts["storage"] = json!(health.storage.label());
|
|
10591
|
+
facts["recovery"] = json!(health.recovery.to_string());
|
|
10592
|
+
facts["sequence"] = json!(database.sequence().unwrap_or(0));
|
|
10593
|
+
check("durable log valid", true, format!("durable format {}", health.durable_format));
|
|
10594
|
+
if !check("replay clean", health.storage.is_clean(), health.recovery.to_string()) {
|
|
10595
|
+
state = "degraded";
|
|
10596
|
+
}
|
|
10597
|
+
let read = database.list_collection_page("_flow_capabilities", None, 1);
|
|
10598
|
+
if !check("read path", read.is_ok(), read.as_ref().map(|_| "bounded read succeeded".to_string()).unwrap_or_else(|error| error.to_string())) {
|
|
10599
|
+
state = "unavailable";
|
|
10600
|
+
}
|
|
10601
|
+
let probe_key = "_feltdb_doctor:probe";
|
|
10602
|
+
let wrote = database
|
|
10603
|
+
.insert(probe_key, json!({ "probe": true }))
|
|
10604
|
+
.and_then(|_| database.get_value(probe_key))
|
|
10605
|
+
.map(|value| value.is_some());
|
|
10606
|
+
if !check("mutation path", matches!(wrote, Ok(true)), match &wrote { Ok(true) => "write and read-back on the private copy".into(), Ok(false) => "write was not readable".into(), Err(error) => error.to_string() }) {
|
|
10607
|
+
state = "unavailable";
|
|
10608
|
+
}
|
|
10609
|
+
}
|
|
10610
|
+
}
|
|
10611
|
+
}
|
|
10612
|
+
if let Ok(cluster) = ClusterStore::load(data.with_extension("cluster.json"), Vec::new()) {
|
|
10613
|
+
let pending = matches!(cluster.proposal().map(|value| value.phase), Some(ProposalPhase::Preparing | ProposalPhase::Prepared));
|
|
10614
|
+
facts["pending_recovery"] = json!(pending);
|
|
10615
|
+
check("no pending membership recovery", !pending, if pending { "a membership change is mid-flight".into() } else { "none".into() });
|
|
10616
|
+
if pending && state == "healthy" {
|
|
10617
|
+
state = "recovering";
|
|
10618
|
+
}
|
|
10619
|
+
}
|
|
10620
|
+
}
|
|
10621
|
+
if let Some(archive) = option("--backup") {
|
|
10622
|
+
let started = Instant::now();
|
|
10623
|
+
let report = feltdb_server::backup::verification_report(std::path::Path::new(&archive), started, "verify");
|
|
10624
|
+
let restorable = if report.status == "ok" { verify_restorable(std::path::Path::new(&report.path)).map_err(|error| error.to_string()) } else { Err(report.error.clone().unwrap_or_default()) };
|
|
10625
|
+
check("supplied backup restorable", restorable.is_ok(), restorable.unwrap_or_else(|error| error));
|
|
10626
|
+
facts["backup"] = json!(report);
|
|
10627
|
+
}
|
|
10628
|
+
let _ = std::fs::remove_dir_all(&scratch);
|
|
10629
|
+
let healthy_requires_all = checks.iter().all(|entry| entry["status"] == "PASS");
|
|
10630
|
+
if state == "healthy" && !healthy_requires_all {
|
|
10631
|
+
state = "degraded";
|
|
10632
|
+
}
|
|
10633
|
+
let report = json!({ "command": "doctor", "state": state, "facts": facts, "checks": checks });
|
|
10634
|
+
if json_output {
|
|
10635
|
+
println!("{}", serde_json::to_string_pretty(&report)?);
|
|
10636
|
+
} else {
|
|
10637
|
+
println!("FeltDB doctor: {state}");
|
|
10638
|
+
for entry in report["checks"].as_array().into_iter().flatten() {
|
|
10639
|
+
println!(" {} {}: {}", if entry["status"] == "PASS" { "✓" } else { "✗" }, entry["name"].as_str().unwrap_or(""), entry["detail"].as_str().unwrap_or(""));
|
|
10640
|
+
}
|
|
10641
|
+
}
|
|
10642
|
+
if state != "healthy" {
|
|
10643
|
+
std::process::exit(1);
|
|
10644
|
+
}
|
|
10645
|
+
Ok(true)
|
|
10646
|
+
}
|
|
10647
|
+
|
|
10648
|
+
/// Prove a bundle restores into a database this build can open.
|
|
10649
|
+
///
|
|
10650
|
+
/// Checksums prove the bytes are the ones recorded; they cannot prove the
|
|
10651
|
+
/// recorded bytes are a FeltDB database in a format this build understands.
|
|
10652
|
+
/// This restores into a private temporary directory, opens it, and reads it —
|
|
10653
|
+
/// the same path `backup restore` and a subsequent server start take — and
|
|
10654
|
+
/// never touches the bundle or any live database.
|
|
10655
|
+
fn verify_restorable(archive: &std::path::Path) -> Result<String, Box<dyn std::error::Error>> {
|
|
10656
|
+
let manifest = verify_bundle(archive)?;
|
|
10657
|
+
let has_log = manifest.entries.iter().any(|entry| entry.path == "files/state.log");
|
|
10658
|
+
let has_snapshot = manifest
|
|
10659
|
+
.entries
|
|
10660
|
+
.iter()
|
|
10661
|
+
.any(|entry| entry.path == "files/database.snapshot.json");
|
|
10662
|
+
if !has_log && !has_snapshot {
|
|
10663
|
+
return Err("backup holds no authoritative state (neither state.log nor database.snapshot.json)".into());
|
|
10664
|
+
}
|
|
10665
|
+
let scratch = std::env::temp_dir().join(format!(
|
|
10666
|
+
"feltdb-backup-verify-{}-{}",
|
|
10667
|
+
std::process::id(),
|
|
10668
|
+
rand::random::<u64>()
|
|
10669
|
+
));
|
|
10670
|
+
let restored = scratch.join("restored");
|
|
10671
|
+
let result = (|| -> Result<String, Box<dyn std::error::Error>> {
|
|
10672
|
+
std::fs::create_dir_all(&scratch)?;
|
|
10673
|
+
restore_bundle(archive, &restored)?;
|
|
10674
|
+
let database = FeltDb::open(restored.join("files/state.log"))?;
|
|
10675
|
+
if has_snapshot {
|
|
10676
|
+
let snapshot: DatabaseSnapshot = serde_json::from_slice(&std::fs::read(
|
|
10677
|
+
restored.join("files/database.snapshot.json"),
|
|
10678
|
+
)?)?;
|
|
10679
|
+
database.install_snapshot(snapshot)?;
|
|
10680
|
+
}
|
|
10681
|
+
let health = database.health();
|
|
10682
|
+
Ok(format!(
|
|
10683
|
+
"restorable: durable format {}, storage {}, sequence {}",
|
|
10684
|
+
health.durable_format,
|
|
10685
|
+
health.storage.label(),
|
|
10686
|
+
database.sequence()?
|
|
10687
|
+
))
|
|
10688
|
+
})();
|
|
10689
|
+
let _ = std::fs::remove_dir_all(&scratch);
|
|
10690
|
+
result
|
|
10691
|
+
}
|
|
10692
|
+
|
|
10413
10693
|
fn manage_application_runtime() -> Result<bool, Box<dyn std::error::Error>> {
|
|
10414
10694
|
let arguments: Vec<String> = std::env::args().collect();
|
|
10415
10695
|
if arguments.get(1).map(String::as_str) != Some("app") {
|
|
@@ -10512,12 +10792,24 @@ async fn create_online_backup(
|
|
|
10512
10792
|
.cloned()
|
|
10513
10793
|
.collect();
|
|
10514
10794
|
let output = PathBuf::from(request.output);
|
|
10795
|
+
let source = feltdb_server::backup::BackupSource {
|
|
10796
|
+
data: state
|
|
10797
|
+
.backup_artifacts
|
|
10798
|
+
.iter()
|
|
10799
|
+
.find(|(label, _)| label == "state.log")
|
|
10800
|
+
.and_then(|(_, path)| feltdb_server::backup::normalize_path(path).ok())
|
|
10801
|
+
.map(|path| path.display().to_string()),
|
|
10802
|
+
state_sha256: None,
|
|
10803
|
+
server_version: Some(env!("CARGO_PKG_VERSION").into()),
|
|
10804
|
+
durable_format: Some(feltdb::DURABLE_FORMAT_VERSION),
|
|
10805
|
+
};
|
|
10515
10806
|
let manifest = tokio::task::spawn_blocking(move || {
|
|
10516
10807
|
let _guard = guard;
|
|
10517
|
-
|
|
10808
|
+
feltdb_server::backup::create_bundle_with_source(
|
|
10518
10809
|
&output,
|
|
10519
10810
|
&artifacts,
|
|
10520
10811
|
&[("database.snapshot.json".to_string(), snapshot_bytes)],
|
|
10812
|
+
source,
|
|
10521
10813
|
)
|
|
10522
10814
|
})
|
|
10523
10815
|
.await
|
|
@@ -12759,6 +13051,11 @@ struct BoundedQueryRequest {
|
|
|
12759
13051
|
limit: usize,
|
|
12760
13052
|
#[serde(default)]
|
|
12761
13053
|
cursor: Option<String>,
|
|
13054
|
+
/// Semantic composition over the deterministic page, per
|
|
13055
|
+
/// `docs/reference/semantic-query.md`. Bound into the request hash so a
|
|
13056
|
+
/// continuation cursor is valid only under the clause it was issued for.
|
|
13057
|
+
#[serde(default)]
|
|
13058
|
+
semantic: Option<feltdb::SemanticQueryClause>,
|
|
12762
13059
|
}
|
|
12763
13060
|
|
|
12764
13061
|
#[derive(Clone, Deserialize, Serialize)]
|
|
@@ -12781,6 +13078,63 @@ struct BoundedQueryPage {
|
|
|
12781
13078
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
12782
13079
|
next_cursor: Option<String>,
|
|
12783
13080
|
exhausted: bool,
|
|
13081
|
+
/// Present exactly when the request carried a semantic clause. Its
|
|
13082
|
+
/// `annotations` align with `records`.
|
|
13083
|
+
#[serde(skip_serializing_if = "Option::is_none")]
|
|
13084
|
+
semantic: Option<BoundedQuerySemanticResult>,
|
|
13085
|
+
}
|
|
13086
|
+
|
|
13087
|
+
#[derive(Serialize)]
|
|
13088
|
+
struct BoundedQuerySemanticResult {
|
|
13089
|
+
complete: bool,
|
|
13090
|
+
stage: feltdb::SemanticStageSummary,
|
|
13091
|
+
annotations: Vec<Option<feltdb::SemanticAnnotation>>,
|
|
13092
|
+
unevaluated: Vec<feltdb::SemanticUnevaluated>,
|
|
13093
|
+
}
|
|
13094
|
+
|
|
13095
|
+
/// The deployment's semantic runtime binding, from `FELTDB_SEMANTIC_RUNTIME`.
|
|
13096
|
+
///
|
|
13097
|
+
/// The variable names a JSON file in the semantic-decision transport's runtime
|
|
13098
|
+
/// shape (`{"kind": "deterministic", "metadata": {...}, "responses": {...}}`).
|
|
13099
|
+
/// A deployment that sets nothing has no binding, and every semantic query
|
|
13100
|
+
/// fails with `SEMANTIC_PROVIDER_UNAVAILABLE` rather than answering the
|
|
13101
|
+
/// deterministic part as if the clause had been honored. A file that cannot be
|
|
13102
|
+
/// read or parsed is a startup failure: a misconfigured binding must not look
|
|
13103
|
+
/// like an unconfigured one.
|
|
13104
|
+
fn configured_semantic_runtime(
|
|
13105
|
+
) -> Result<Option<Arc<dyn feltdb::DecisionRuntime + Send + Sync>>, String> {
|
|
13106
|
+
let Ok(path) = std::env::var("FELTDB_SEMANTIC_RUNTIME") else {
|
|
13107
|
+
return Ok(None);
|
|
13108
|
+
};
|
|
13109
|
+
let source = std::fs::read_to_string(&path)
|
|
13110
|
+
.map_err(|error| format!("FELTDB_SEMANTIC_RUNTIME {path} is unreadable: {error}"))?;
|
|
13111
|
+
let runtime: feltdb::DecisionTransportRuntime =
|
|
13112
|
+
serde_json::from_str(&source).map_err(|error| {
|
|
13113
|
+
format!("FELTDB_SEMANTIC_RUNTIME {path} is not a runtime binding: {error}")
|
|
13114
|
+
})?;
|
|
13115
|
+
feltdb::configured_decision_runtime(&runtime)
|
|
13116
|
+
.map(Some)
|
|
13117
|
+
.map_err(|error| format!("FELTDB_SEMANTIC_RUNTIME {path} is refused: {error}"))
|
|
13118
|
+
}
|
|
13119
|
+
|
|
13120
|
+
fn semantic_query_error(error: feltdb::SemanticQueryError) -> ApiError {
|
|
13121
|
+
let status = match error.code.as_str() {
|
|
13122
|
+
"INVALID_QUERY" | "INVALID_CURSOR" => StatusCode::UNPROCESSABLE_ENTITY,
|
|
13123
|
+
"SEMANTIC_PROVIDER_UNAVAILABLE" | "DECISION_STORAGE_ERROR" => {
|
|
13124
|
+
StatusCode::SERVICE_UNAVAILABLE
|
|
13125
|
+
}
|
|
13126
|
+
"SEMANTIC_UNSUPPORTED" => StatusCode::NOT_IMPLEMENTED,
|
|
13127
|
+
"SEMANTIC_INVALID_RESULT" => StatusCode::BAD_GATEWAY,
|
|
13128
|
+
_ => StatusCode::BAD_REQUEST,
|
|
13129
|
+
};
|
|
13130
|
+
ApiError::structured(
|
|
13131
|
+
status,
|
|
13132
|
+
json!({
|
|
13133
|
+
"code": error.code,
|
|
13134
|
+
"message": error.message,
|
|
13135
|
+
"http_status": status.as_u16(),
|
|
13136
|
+
}),
|
|
13137
|
+
)
|
|
12784
13138
|
}
|
|
12785
13139
|
|
|
12786
13140
|
fn bounded_query_error(code: &str, message: impl Into<String>) -> ApiError {
|
|
@@ -12794,11 +13148,15 @@ fn bounded_query_error(code: &str, message: impl Into<String>) -> ApiError {
|
|
|
12794
13148
|
}
|
|
12795
13149
|
|
|
12796
13150
|
fn bounded_query_hash(request: &BoundedQueryRequest) -> Result<String, ApiError> {
|
|
13151
|
+
// The semantic clause is part of the request a cursor is bound to, so a
|
|
13152
|
+
// continuation presented under a different clause is `INVALID_CURSOR`. The
|
|
13153
|
+
// clause's identity, never its results, enters the hash.
|
|
12797
13154
|
let context = json!({
|
|
12798
13155
|
"collection": request.collection,
|
|
12799
13156
|
"where": request.conditions,
|
|
12800
13157
|
"orderBy": request.order_by,
|
|
12801
13158
|
"limit": request.limit,
|
|
13159
|
+
"semantic": request.semantic.as_ref().map(feltdb::semantic_clause_hash),
|
|
12802
13160
|
});
|
|
12803
13161
|
let bytes = serde_json::to_vec(&context)
|
|
12804
13162
|
.map_err(|error| bounded_query_error("INVALID_QUERY", error.to_string()))?;
|
|
@@ -13058,10 +13416,15 @@ async fn execute_bounded_query(
|
|
|
13058
13416
|
State(state): State<AppState>,
|
|
13059
13417
|
Extension(principal): Extension<Principal>,
|
|
13060
13418
|
headers: HeaderMap,
|
|
13061
|
-
Json(request): Json<
|
|
13419
|
+
Json(request): Json<Value>,
|
|
13062
13420
|
) -> Result<Json<BoundedQueryPage>, ApiError> {
|
|
13063
13421
|
let _handler =
|
|
13064
13422
|
feltdb::workload_diagnostics::span(feltdb::workload_diagnostics::Phase::HandlerBody);
|
|
13423
|
+
// A request this surface cannot represent — including a semantic clause
|
|
13424
|
+
// with keys the contract does not define — is a structured `INVALID_QUERY`,
|
|
13425
|
+
// so a client can tell a refused clause from a transport failure.
|
|
13426
|
+
let request: BoundedQueryRequest = serde_json::from_value(request)
|
|
13427
|
+
.map_err(|error| bounded_query_error("INVALID_QUERY", error.to_string()))?;
|
|
13065
13428
|
let execution = bounded_query_execution(&headers)?;
|
|
13066
13429
|
validate_segment(&request.collection)?;
|
|
13067
13430
|
if request.limit == 0 || request.limit > MAX_BOUNDED_QUERY_LIMIT {
|
|
@@ -13100,6 +13463,10 @@ async fn execute_bounded_query(
|
|
|
13100
13463
|
));
|
|
13101
13464
|
}
|
|
13102
13465
|
}
|
|
13466
|
+
// A malformed semantic clause fails before any deterministic work runs.
|
|
13467
|
+
if let Some(clause) = &request.semantic {
|
|
13468
|
+
feltdb::validate_semantic_clause(clause).map_err(semantic_query_error)?;
|
|
13469
|
+
}
|
|
13103
13470
|
let hash = bounded_query_hash(&request)?;
|
|
13104
13471
|
let (records, position) = if let Some(token) = &request.cursor {
|
|
13105
13472
|
let cursor = state
|
|
@@ -13187,7 +13554,7 @@ async fn execute_bounded_query(
|
|
|
13187
13554
|
&state,
|
|
13188
13555
|
BoundedQueryCursor {
|
|
13189
13556
|
query_hash: hash,
|
|
13190
|
-
principal_key_id: principal.key_id,
|
|
13557
|
+
principal_key_id: principal.key_id.clone(),
|
|
13191
13558
|
namespace: state.namespace.to_string(),
|
|
13192
13559
|
records: records.clone(),
|
|
13193
13560
|
position: end,
|
|
@@ -13197,10 +13564,80 @@ async fn execute_bounded_query(
|
|
|
13197
13564
|
} else {
|
|
13198
13565
|
None
|
|
13199
13566
|
};
|
|
13567
|
+
// The semantic stage transforms exactly this page. It runs after the
|
|
13568
|
+
// deterministic cursor is issued because continuation is by deterministic
|
|
13569
|
+
// position: the page may return fewer records than `limit` and still not
|
|
13570
|
+
// be exhausted.
|
|
13571
|
+
let semantic = match &request.semantic {
|
|
13572
|
+
None => None,
|
|
13573
|
+
Some(clause) => {
|
|
13574
|
+
let candidates = page
|
|
13575
|
+
.iter()
|
|
13576
|
+
.map(|record| feltdb::SemanticCandidate {
|
|
13577
|
+
record_id: record
|
|
13578
|
+
.get("recordId")
|
|
13579
|
+
.and_then(Value::as_str)
|
|
13580
|
+
.unwrap_or_default()
|
|
13581
|
+
.to_string(),
|
|
13582
|
+
record: record.clone(),
|
|
13583
|
+
})
|
|
13584
|
+
.collect();
|
|
13585
|
+
let composed = feltdb::compose_semantic_page(
|
|
13586
|
+
&state.db,
|
|
13587
|
+
&request.collection,
|
|
13588
|
+
candidates,
|
|
13589
|
+
clause,
|
|
13590
|
+
state
|
|
13591
|
+
.semantic_runtime
|
|
13592
|
+
.as_deref()
|
|
13593
|
+
.map(|runtime| runtime as &dyn feltdb::DecisionRuntime),
|
|
13594
|
+
clause.authorization.as_ref().map(|authorization| {
|
|
13595
|
+
let requested = authorization.principal.as_ref();
|
|
13596
|
+
feltdb::DecisionPrincipal {
|
|
13597
|
+
subject: decision_subject(&principal),
|
|
13598
|
+
tenant_id: requested
|
|
13599
|
+
.map(|value| value.tenant_id.clone())
|
|
13600
|
+
.unwrap_or_default(),
|
|
13601
|
+
application_id: requested
|
|
13602
|
+
.map(|value| value.application_id.clone())
|
|
13603
|
+
.unwrap_or_default(),
|
|
13604
|
+
capability: "state:read".into(),
|
|
13605
|
+
}
|
|
13606
|
+
}),
|
|
13607
|
+
)
|
|
13608
|
+
.map_err(semantic_query_error)?;
|
|
13609
|
+
audit(
|
|
13610
|
+
&state,
|
|
13611
|
+
&principal.key_id,
|
|
13612
|
+
"semantic_query.compose",
|
|
13613
|
+
&format!("{}@{}", request.collection, composed.stage.definition_hash),
|
|
13614
|
+
if composed.complete {
|
|
13615
|
+
"complete"
|
|
13616
|
+
} else {
|
|
13617
|
+
"incomplete"
|
|
13618
|
+
},
|
|
13619
|
+
200,
|
|
13620
|
+
);
|
|
13621
|
+
Some(composed)
|
|
13622
|
+
}
|
|
13623
|
+
};
|
|
13624
|
+
let (page, semantic) = match semantic {
|
|
13625
|
+
None => (page, None),
|
|
13626
|
+
Some(composed) => (
|
|
13627
|
+
composed.records,
|
|
13628
|
+
Some(BoundedQuerySemanticResult {
|
|
13629
|
+
complete: composed.complete,
|
|
13630
|
+
stage: composed.stage,
|
|
13631
|
+
annotations: composed.annotations,
|
|
13632
|
+
unevaluated: composed.unevaluated,
|
|
13633
|
+
}),
|
|
13634
|
+
),
|
|
13635
|
+
};
|
|
13200
13636
|
Ok(Json(BoundedQueryPage {
|
|
13201
13637
|
records: page,
|
|
13202
13638
|
exhausted: next_cursor.is_none(),
|
|
13203
13639
|
next_cursor,
|
|
13640
|
+
semantic,
|
|
13204
13641
|
}))
|
|
13205
13642
|
}
|
|
13206
13643
|
|
|
File without changes
|