diff --git a/beava-design-system/project/assets/org-avatar-280.png b/beava-design-system/project/assets/org-avatar-280.png new file mode 100644 index 00000000..6d044f71 Binary files /dev/null and b/beava-design-system/project/assets/org-avatar-280.png differ diff --git a/beava-design-system/project/assets/repo-social-preview-1280x640.png b/beava-design-system/project/assets/repo-social-preview-1280x640.png new file mode 100644 index 00000000..c69f6c91 Binary files /dev/null and b/beava-design-system/project/assets/repo-social-preview-1280x640.png differ diff --git a/beava-website/project/index.html b/beava-website/project/index.html index 99affb7a..5d9d16d4 100644 --- a/beava-website/project/index.html +++ b/beava-website/project/index.html @@ -457,7 +457,7 @@ bv.get("SiteMetrics") # → {`{median_dwell_1h: 6_250, views_today: 14_207, …}`}{'\n'} {'\n'} # Or over HTTP (the panel above is hitting this exact endpoint):{'\n'} -$ curl https://beava.dev/api/features/site +$ curl https://beava.dev/api/get -d '{`{"table":"SiteMetrics","key":""}`}'
{[ diff --git a/crates/beava-server/src/apply_shard.rs b/crates/beava-server/src/apply_shard.rs index 89499642..143a9288 100644 --- a/crates/beava-server/src/apply_shard.rs +++ b/crates/beava-server/src/apply_shard.rs @@ -261,12 +261,35 @@ impl ApplyShard { }; } - // `force=true` on a destructive payload: pre-remove the - // conflicting descriptors so the apply path treats them - // as new. `execute_register_with_wal` then emits a - // single `RegistryBump` capturing the new payload's - // nodes (and bumps `registry_version`). - if force && !diff.destructive.is_empty() { + // `force=true` carries the explicit "drop existing state and + // replace fully" intent for any descriptor that already + // exists in the registry. We pre-remove every existing + // descriptor that the new payload would change so the + // legacy compute_diff inside `execute_register_with_wal` + // sees them as fully new (no `changed` entries → no + // `registration_conflict`). Two reasons we can't just gate + // on `diff.destructive`: + // + // 1. `classify_register_diff` classifies new fields on an + // existing event source and new aggregations in an + // existing block as `additive`, not destructive. The + // legacy `compute_diff` flags those same shapes as + // `changed: [ConflictDetail]` (schema_mismatch, + // ops_mismatch). Without pre-removal here, the legacy + // path 409s with force silently dropped — the prod + // bug behind the deploy-hetzner failure. + // + // 2. `NewDescriptor` is the only additive variant we + // skip — it's genuinely-new (not in current + // registry), so there's nothing to pre-remove. + // + // Pre-removal drops compiled chains, aggregations, feature + // index entries, and accumulated state for the named + // descriptors. That state loss is the documented force=true + // semantic; callers who want to add a field without losing + // state should send the request without `force` (additive + // path is allowed by `register_check_force_required`). + if force { let mut to_remove: Vec = Vec::new(); for entry in &diff.destructive { match entry { @@ -297,12 +320,31 @@ impl ApplyShard { _ => {} } } + // Additive-against-existing: the descriptor already + // exists, so the legacy `compute_diff` would flag it + // as `changed` and 409 unconditionally. Pre-remove + // here so it lands as a clean add. + for entry in &diff.additive { + match entry { + beava_core::registry_diff::DiffEntry::NewField { event, .. } => { + to_remove.push(event.clone()); + } + beava_core::registry_diff::DiffEntry::NewAgg { table, .. } => { + to_remove.push(table.clone()); + } + // `NewDescriptor` is genuinely-new — not in + // current registry, nothing to pre-remove. + _ => {} + } + } to_remove.sort(); to_remove.dedup(); - self.state - .dev_agg - .registry - .force_remove_descriptors(&to_remove); + if !to_remove.is_empty() { + self.state + .dev_agg + .registry + .force_remove_descriptors(&to_remove); + } } // Register is cold path: delegate to the async WAL-backed diff --git a/crates/beava-server/tests/phase13_4_force_register.rs b/crates/beava-server/tests/phase13_4_force_register.rs index a9b69db5..0f6ff8fd 100644 --- a/crates/beava-server/tests/phase13_4_force_register.rs +++ b/crates/beava-server/tests/phase13_4_force_register.rs @@ -378,3 +378,64 @@ async fn register_destructive_agg_removal_without_force_returns_409() { ts.shutdown().await.ok(); } + +// ─── Test 11 — additive NewField on existing event with force=true succeeds ─ +// +// Regression for the production deploy that 409'd after PR #1 merged. The +// pipeline added `session_id` to the `PageView` event source. Two diff +// systems disagreed on the classification: +// +// - `classify_register_diff` (apply_shard pre-flight): NewField on an +// existing event source → `additive`. No destructive entries. +// - `compute_diff` (legacy, inside `execute_register_with_wal`): the +// event source's schema differs → `changed: [ConflictDetail]`, +// unconditionally returns `RegisterOutcome::Conflict` (HTTP 409 +// `registration_conflict`) regardless of `force=true` on the wire. +// +// `apply_shard`'s force-handling block at the time of the bug only ran +// `force_remove_descriptors` when `diff.destructive` was non-empty. Pure +// additive changes against existing descriptors slipped through the +// pre-flight, hit the legacy compute_diff inside execute_register, and +// 409'd — even with `force=true` set on the request body. +// +// Fixed behavior: with `force=true`, additive entries that target an +// existing descriptor (NewField on event, NewAgg on table) MUST also be +// pre-removed so execute_register sees them as fully new. The +// caller-explicit `force=true` carries the "drop existing state" intent +// for both destructive AND additive-against-existing changes. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn register_additive_new_field_on_existing_event_with_force_true_succeeds() { + let ts = TestServer::spawn().await.expect("spawn"); + let (status_a, body_a) = post_register(&ts, &baseline_payload()).await; + assert!((200..300).contains(&status_a)); + let v_a = body_a["registry_version"] + .as_u64() + .unwrap_or_else(|| panic!("baseline response must include registry_version: {body_a:#}")); + + // Re-register the SAME shape with one new field added to the existing + // `Tx` event source. `classify_register_diff` will emit + // `additive: [NewField{event:Tx, field:session_id}]`, + // `destructive: []`. With force=true, the request must succeed. + let mut payload_b = baseline_payload(); + payload_b["nodes"][0]["schema"]["fields"]["session_id"] = json!("str"); + payload_b["force"] = json!(true); + + let (status_b, body_b) = post_register(&ts, &payload_b).await; + assert!( + (200..300).contains(&status_b), + "force=true on additive NewField against existing event must succeed, got status={status_b}, body={body_b:#}" + ); + assert!( + body_b.get("error").is_none(), + "force=true additive register must not return error envelope, got: {body_b:#}" + ); + let v_b = body_b["registry_version"] + .as_u64() + .unwrap_or_else(|| panic!("force=true response must include registry_version: {body_b:#}")); + assert!( + v_b > v_a, + "registry_version must bump after additive force=true apply: was {v_a}, now {v_b}" + ); + + ts.shutdown().await.ok(); +}