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.
@@ -75,7 +75,7 @@ use feltdb_server::{
75
75
  authenticated_principal::AuthenticatedPrincipal,
76
76
  authorization::{authorize, AuthorizationRequest, Subject},
77
77
  backup::{
78
- create_bundle, create_bundle_with_virtual, restore_bundle, select_bundle_at,
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 = PathBuf::from(option("--output").ok_or("backup create requires --output")?);
10384
- let manifest = create_bundle(&output, &server_artifacts(&data, &keys, &audit))?;
10385
- println!("Backup created: {} (format {}, {} files)", output.display(), manifest.format, manifest.entries.len());
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 = PathBuf::from(option("--archive").ok_or("backup verify requires --archive")?);
10389
- let manifest = verify_bundle(&archive)?;
10390
- println!("Backup verified: {} (format {}, {} files)", archive.display(), manifest.format, manifest.entries.len());
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 = PathBuf::from(option("--output").ok_or("backup restore requires --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
- println!("Backup restored: {} (format {}, {} files)", output.display(), manifest.format, manifest.entries.len());
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("usage: feltdb-server backup <create|verify|restore> [--data path] [--keys path] [--audit path] --output directory | --archive directory [--at-ms timestamp]".into()),
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
- create_bundle_with_virtual(
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<BoundedQueryRequest>,
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
 
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "create-feltdb",
3
3
  "private": false,
4
4
  "description": "Create a new FeltDB application with one command",
5
- "version": "0.11.4",
5
+ "version": "0.11.8",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "create-feltdb": "bin/create-feltdb.js"