Skip to main content

allsource_core/infrastructure/web/
api.rs

1#[cfg(feature = "replication")]
2use crate::infrastructure::replication::ReplicationMode;
3use crate::{
4    application::{
5        dto::{
6            AckRequest, ConsumerEventDto, ConsumerEventsResponse, ConsumerResponse,
7            DetectDuplicatesRequest, DetectDuplicatesResponse, DuplicateGroup, EntitySummary,
8            EventDto, IngestEventRequest, IngestEventResponse, IngestEventsBatchRequest,
9            IngestEventsBatchResponse, ListEntitiesRequest, ListEntitiesResponse,
10            QueryEventsRequest, QueryEventsResponse, RegisterConsumerRequest,
11        },
12        services::{
13            analytics::{
14                AnalyticsEngine, CorrelationRequest, CorrelationResponse, EventFrequencyRequest,
15                EventFrequencyResponse, StatsSummaryRequest, StatsSummaryResponse,
16            },
17            pipeline::{PipelineConfig, PipelineStats},
18            replay::{ReplayProgress, StartReplayRequest, StartReplayResponse},
19            schema::{
20                CompatibilityMode, RegisterSchemaRequest, RegisterSchemaResponse,
21                ValidateEventRequest, ValidateEventResponse,
22            },
23            webhook::{RegisterWebhookRequest, UpdateWebhookRequest},
24        },
25    },
26    domain::{
27        entities::{Event, SchemaEnforcement},
28        value_objects::TenantId,
29    },
30    error::Result,
31    infrastructure::{
32        persistence::{
33            compaction::CompactionResult,
34            snapshot::{
35                CreateSnapshotRequest, CreateSnapshotResponse, ListSnapshotsRequest,
36                ListSnapshotsResponse, SnapshotInfo,
37            },
38        },
39        query::{
40            geospatial::GeoQueryRequest,
41            graphql::{GraphQLError, GraphQLRequest, GraphQLResponse},
42        },
43        security::middleware::OptionalAuth,
44        web::api_v1::AppState,
45    },
46    store::{EventStore, EventTypeInfo, StreamInfo},
47};
48use axum::{
49    Json, Router,
50    extract::{Path, Query, State, WebSocketUpgrade},
51    response::{IntoResponse, Response},
52    routing::{get, post, put},
53};
54use serde::Deserialize;
55use std::sync::Arc;
56use tower_http::{
57    cors::{Any, CorsLayer},
58    trace::TraceLayer,
59};
60
61type SharedStore = Arc<EventStore>;
62
63/// Wait for follower ACK(s) in semi-sync/sync replication modes.
64///
65/// In async mode (default), returns immediately. In semi-sync mode, waits for
66/// at least 1 follower to ACK the current WAL offset. In sync mode, waits for
67/// all followers. If the timeout expires, logs a warning and continues (degraded mode).
68#[cfg(feature = "replication")]
69async fn await_replication_ack(state: &AppState) {
70    let shipper_guard = state.wal_shipper.read().await;
71    if let Some(ref shipper) = *shipper_guard {
72        let mode = shipper.replication_mode();
73        if mode == ReplicationMode::Async {
74            return;
75        }
76
77        let target_offset = shipper.current_leader_offset();
78        if target_offset == 0 {
79            return;
80        }
81
82        let shipper = Arc::clone(shipper);
83        // Drop the read guard before the async wait to avoid holding it across await
84        drop(shipper_guard);
85
86        let timer = state
87            .store
88            .metrics()
89            .replication_ack_wait_seconds
90            .start_timer();
91        let acked = shipper.wait_for_ack(target_offset).await;
92        timer.observe_duration();
93
94        if !acked {
95            tracing::warn!(
96                "Replication ACK timeout in {} mode (offset {}). \
97                 Write succeeded locally but follower confirmation pending.",
98                mode,
99                target_offset,
100            );
101        }
102    }
103}
104#[cfg(not(feature = "replication"))]
105async fn await_replication_ack(_state: &AppState) {
106    // No-op in community edition
107}
108
109pub async fn serve(store: SharedStore, addr: &str) -> anyhow::Result<()> {
110    let app = Router::new()
111        .route("/health", get(health))
112        .route("/metrics", get(prometheus_metrics)) // v0.6: Prometheus metrics endpoint
113        .route("/api/v1/events", post(ingest_event))
114        .route("/api/v1/events/batch", post(ingest_events_batch))
115        .route("/api/v1/events/query", get(query_events))
116        .route("/api/v1/events/{event_id}", get(get_event_by_id))
117        .route("/api/v1/events/stream", get(events_websocket)) // v0.2: WebSocket streaming
118        // v0.10: Stream and event type discovery endpoints
119        .route("/api/v1/streams", get(list_streams))
120        .route("/api/v1/event-types", get(list_event_types))
121        .route("/api/v1/entities/duplicates", get(detect_duplicates))
122        .route("/api/v1/entities/{entity_id}/state", get(get_entity_state))
123        .route(
124            "/api/v1/entities/{entity_id}/snapshot",
125            get(get_entity_snapshot),
126        )
127        .route("/api/v1/stats", get(get_stats))
128        // v0.2: Advanced analytics endpoints
129        .route("/api/v1/analytics/frequency", get(analytics_frequency))
130        .route("/api/v1/analytics/summary", get(analytics_summary))
131        .route("/api/v1/analytics/correlation", get(analytics_correlation))
132        // v0.2: Snapshot management endpoints
133        .route("/api/v1/snapshots", post(create_snapshot))
134        .route("/api/v1/snapshots", get(list_snapshots))
135        .route(
136            "/api/v1/snapshots/{entity_id}/latest",
137            get(get_latest_snapshot),
138        )
139        // v0.2: Compaction endpoints
140        .route("/api/v1/compaction/trigger", post(trigger_compaction))
141        .route("/api/v1/compaction/stats", get(compaction_stats))
142        // v0.5: Schema registry endpoints
143        .route("/api/v1/schemas", post(register_schema))
144        .route("/api/v1/schemas", get(list_subjects))
145        .route("/api/v1/schemas/{subject}", get(get_schema))
146        .route(
147            "/api/v1/schemas/{subject}/versions",
148            get(list_schema_versions),
149        )
150        .route("/api/v1/schemas/validate", post(validate_event_schema))
151        .route(
152            "/api/v1/schemas/{subject}/compatibility",
153            put(set_compatibility_mode),
154        )
155        // v0.5: Replay and projection rebuild endpoints
156        .route("/api/v1/replay", post(start_replay))
157        .route("/api/v1/replay", get(list_replays))
158        .route("/api/v1/replay/{replay_id}", get(get_replay_progress))
159        .route("/api/v1/replay/{replay_id}/cancel", post(cancel_replay))
160        .route(
161            "/api/v1/replay/{replay_id}",
162            axum::routing::delete(delete_replay),
163        )
164        // v0.5: Stream processing pipeline endpoints
165        .route("/api/v1/pipelines", post(register_pipeline))
166        .route("/api/v1/pipelines", get(list_pipelines))
167        .route("/api/v1/pipelines/stats", get(all_pipeline_stats))
168        .route("/api/v1/pipelines/{pipeline_id}", get(get_pipeline))
169        .route(
170            "/api/v1/pipelines/{pipeline_id}",
171            axum::routing::delete(remove_pipeline),
172        )
173        .route(
174            "/api/v1/pipelines/{pipeline_id}/stats",
175            get(get_pipeline_stats),
176        )
177        .route("/api/v1/pipelines/{pipeline_id}/reset", put(reset_pipeline))
178        // v0.7: Projection State API for Query Service integration
179        .route("/api/v1/projections", get(list_projections))
180        .route("/api/v1/projections/{name}", get(get_projection))
181        .route(
182            "/api/v1/projections/{name}",
183            axum::routing::delete(delete_projection),
184        )
185        .route(
186            "/api/v1/projections/{name}/state",
187            get(get_projection_state_summary),
188        )
189        .route("/api/v1/projections/{name}/reset", post(reset_projection))
190        .route(
191            "/api/v1/projections/{name}/{entity_id}/state",
192            get(get_projection_state),
193        )
194        .route(
195            "/api/v1/projections/{name}/{entity_id}/state",
196            post(save_projection_state),
197        )
198        .route(
199            "/api/v1/projections/{name}/{entity_id}/state",
200            put(save_projection_state),
201        )
202        .route(
203            "/api/v1/projections/{name}/bulk",
204            post(bulk_get_projection_states),
205        )
206        .route(
207            "/api/v1/projections/{name}/bulk/save",
208            post(bulk_save_projection_states),
209        )
210        // v0.11: Webhook management endpoints
211        .route("/api/v1/webhooks", post(register_webhook))
212        .route("/api/v1/webhooks", get(list_webhooks))
213        .route("/api/v1/webhooks/{webhook_id}", get(get_webhook))
214        .route("/api/v1/webhooks/{webhook_id}", put(update_webhook))
215        .route(
216            "/api/v1/webhooks/{webhook_id}",
217            axum::routing::delete(delete_webhook),
218        )
219        .route(
220            "/api/v1/webhooks/{webhook_id}/deliveries",
221            get(list_webhook_deliveries),
222        )
223        // v2.0: Advanced query features
224        .route("/api/v1/graphql", post(graphql_query))
225        .route("/api/v1/geospatial/query", post(geo_query))
226        .route("/api/v1/geospatial/stats", get(geo_stats))
227        .route("/api/v1/exactly-once/stats", get(exactly_once_stats))
228        .route(
229            "/api/v1/schema-evolution/history/{event_type}",
230            get(schema_evolution_history),
231        )
232        .route(
233            "/api/v1/schema-evolution/schema/{event_type}",
234            get(schema_evolution_schema),
235        )
236        .route(
237            "/api/v1/schema-evolution/stats",
238            get(schema_evolution_stats),
239        )
240        .layer(
241            CorsLayer::new()
242                .allow_origin(Any)
243                .allow_methods(Any)
244                .allow_headers(Any),
245        )
246        .layer(TraceLayer::new_for_http())
247        .with_state(store);
248
249    let listener = tokio::net::TcpListener::bind(addr).await?;
250    axum::serve(listener, app).await?;
251
252    Ok(())
253}
254
255pub async fn health() -> impl IntoResponse {
256    Json(serde_json::json!({
257        "status": "healthy",
258        "service": "allsource-core",
259        "version": env!("CARGO_PKG_VERSION")
260    }))
261}
262
263// v0.6: Prometheus metrics endpoint
264pub async fn prometheus_metrics(State(store): State<SharedStore>) -> impl IntoResponse {
265    let metrics = store.metrics();
266
267    match metrics.encode() {
268        Ok(encoded) => Response::builder()
269            .status(200)
270            .header("Content-Type", "text/plain; version=0.0.4")
271            .body(encoded)
272            .unwrap()
273            .into_response(),
274        Err(e) => Response::builder()
275            .status(500)
276            .body(format!("Error encoding metrics: {e}"))
277            .unwrap()
278            .into_response(),
279    }
280}
281
282pub async fn ingest_event(
283    State(store): State<SharedStore>,
284    Json(req): Json<IngestEventRequest>,
285) -> Result<Json<IngestEventResponse>> {
286    let expected_version = req.expected_version;
287
288    let tenant_id = req.tenant_id.unwrap_or_else(|| "default".to_string());
289    let event = Event::from_strings(
290        req.event_type,
291        req.entity_id,
292        tenant_id,
293        req.payload,
294        req.metadata,
295    )?;
296
297    let event_id = event.id;
298    let timestamp = event.timestamp;
299
300    let new_version = store.ingest_with_expected_version(&event, expected_version)?;
301
302    tracing::info!("Event ingested: {}", event_id);
303
304    Ok(Json(IngestEventResponse {
305        event_id,
306        timestamp,
307        version: Some(new_version),
308    }))
309}
310
311/// Ingest a single event with semi-sync/sync replication ACK waiting.
312///
313/// Used by the v1 API. Tenant comes from `req.tenant_id` — the Control Plane
314/// delegation layer sets it from the authenticated caller before forwarding.
315/// Core is internal-only and does not authenticate public traffic.
316/// Look up the tenant's `SchemaEnforcement` mode and, if non-permissive,
317/// validate the event payload against any registered schema for the
318/// event_type.
319///
320/// Fast path: `Permissive` (default for unconfigured tenants AND for tenants
321/// not present in the repo, like dev/test setups) returns `Ok(())` without
322/// touching the schema registry — preserves the pre-v0.21.5 ingest cost.
323///
324/// `Warn`: validation runs, violations log at WARN, the write proceeds.
325/// `Strict`: violations return `AllSourceError::SchemaViolation` → 422 with
326/// the structured body the HTTP layer builds in `error.rs`.
327///
328/// If no schema is registered for the event_type, validation is a no-op
329/// regardless of mode — this matches the principle that schemas are
330/// opt-in per event_type, not per tenant.
331async fn enforce_schema_if_configured(
332    state: &AppState,
333    tenant_id: &str,
334    event: &Event,
335) -> Result<()> {
336    // Cheapest possible lookup: parse the tenant_id; if it fails, treat as
337    // permissive (defensive — Event::from_strings already validated, but
338    // this keeps the contract clear).
339    let Ok(parsed) = TenantId::new(tenant_id.to_string()) else {
340        return Ok(());
341    };
342    let mode = match state.tenant_repo.find_by_id(&parsed).await {
343        Ok(Some(t)) => t.schema_enforcement(),
344        // Unknown tenant → permissive. Avoids breaking the default tenant
345        // and any dev setups that ingest without pre-registering tenants.
346        _ => SchemaEnforcement::Permissive,
347    };
348    if matches!(mode, SchemaEnforcement::Permissive) {
349        return Ok(());
350    }
351
352    // Schema lookup keyed by event_type. Latest version only (None) — schema
353    // evolution is a separate concern; tenants pin a version via their own
354    // registration flow if they need to.
355    let registry = state.store.schema_registry();
356    let Ok(schema) = registry.get_schema(event.event_type.as_str(), None) else {
357        // No schema registered for this event_type → fast path, regardless
358        // of enforcement mode. Schemas are opt-in.
359        return Ok(());
360    };
361
362    let result = registry
363        .validate(
364            event.event_type.as_str(),
365            Some(schema.version),
366            &event.payload,
367        )
368        .map_err(|e| crate::error::AllSourceError::InternalError(e.to_string()))?;
369
370    if result.valid {
371        return Ok(());
372    }
373
374    match mode {
375        SchemaEnforcement::Strict => Err(crate::error::AllSourceError::SchemaViolation {
376            event_type: event.event_type.as_str().to_string(),
377            schema_version: result.schema_version,
378            errors: result.errors,
379        }),
380        SchemaEnforcement::Warn => {
381            tracing::warn!(
382                tenant = %tenant_id,
383                event_type = %event.event_type.as_str(),
384                schema_version = result.schema_version,
385                errors = ?result.errors,
386                "schema violation (warn mode — write accepted)"
387            );
388            Ok(())
389        }
390        // Already handled above
391        SchemaEnforcement::Permissive => Ok(()),
392    }
393}
394
395pub async fn ingest_event_v1(
396    State(state): State<AppState>,
397    Json(req): Json<IngestEventRequest>,
398) -> Result<Json<IngestEventResponse>> {
399    let expected_version = req.expected_version;
400
401    let tenant_id = req.tenant_id.unwrap_or_else(|| "default".to_string());
402
403    let event = Event::from_strings(
404        req.event_type,
405        req.entity_id,
406        tenant_id.clone(),
407        req.payload,
408        req.metadata,
409    )?;
410
411    // Per-tenant schema enforcement (Permissive is the fast path — no
412    // tenant lookup, no schema query). See neotoma-gaps bead t-0795.
413    enforce_schema_if_configured(&state, &tenant_id, &event).await?;
414
415    let event_id = event.id;
416    let timestamp = event.timestamp;
417
418    let new_version = state
419        .store
420        .ingest_with_expected_version(&event, expected_version)?;
421
422    // Semi-sync/sync: wait for follower ACK(s) before returning
423    await_replication_ack(&state).await;
424
425    tracing::info!("Event ingested: {}", event_id);
426
427    Ok(Json(IngestEventResponse {
428        event_id,
429        timestamp,
430        version: Some(new_version),
431    }))
432}
433
434/// Batch ingest multiple events in a single request
435///
436/// This endpoint allows ingesting multiple events atomically, which is more
437/// efficient than making individual requests for each event.
438pub async fn ingest_events_batch(
439    State(store): State<SharedStore>,
440    Json(req): Json<IngestEventsBatchRequest>,
441) -> Result<Json<IngestEventsBatchResponse>> {
442    let total = req.events.len();
443    let mut ingested_events = Vec::with_capacity(total);
444
445    for event_req in req.events {
446        let tenant_id = event_req.tenant_id.unwrap_or_else(|| "default".to_string());
447        let expected_version = event_req.expected_version;
448
449        let event = Event::from_strings(
450            event_req.event_type,
451            event_req.entity_id,
452            tenant_id,
453            event_req.payload,
454            event_req.metadata,
455        )?;
456
457        // Note: the non-v1 batch path uses State<SharedStore>, not AppState,
458        // so it has no tenant_repo to check enforcement against. Schema
459        // enforcement is wired through the v1 batch handler below — this
460        // path remains permissive (it predates the tenant_repo wiring).
461
462        let event_id = event.id;
463        let timestamp = event.timestamp;
464
465        let new_version = store.ingest_with_expected_version(&event, expected_version)?;
466
467        ingested_events.push(IngestEventResponse {
468            event_id,
469            timestamp,
470            version: Some(new_version),
471        });
472    }
473
474    let ingested = ingested_events.len();
475    tracing::info!("Batch ingested {} events", ingested);
476
477    Ok(Json(IngestEventsBatchResponse {
478        total,
479        ingested,
480        events: ingested_events,
481    }))
482}
483
484/// Batch ingest with semi-sync/sync replication ACK waiting.
485///
486/// Used by the v1 API. Per-event tenant comes from `event_req.tenant_id` — the
487/// Control Plane delegation layer sets it from the authenticated caller before
488/// forwarding. Core is internal-only and does not authenticate public traffic.
489pub async fn ingest_events_batch_v1(
490    State(state): State<AppState>,
491    Json(req): Json<IngestEventsBatchRequest>,
492) -> Result<Json<IngestEventsBatchResponse>> {
493    let total = req.events.len();
494    let mut ingested_events = Vec::with_capacity(total);
495
496    for event_req in req.events {
497        let tenant_id = event_req.tenant_id.unwrap_or_else(|| "default".to_string());
498        let expected_version = event_req.expected_version;
499
500        let event = Event::from_strings(
501            event_req.event_type,
502            event_req.entity_id,
503            tenant_id.clone(),
504            event_req.payload,
505            event_req.metadata,
506        )?;
507
508        enforce_schema_if_configured(&state, &tenant_id, &event).await?;
509
510        let event_id = event.id;
511        let timestamp = event.timestamp;
512
513        let new_version = state
514            .store
515            .ingest_with_expected_version(&event, expected_version)?;
516
517        ingested_events.push(IngestEventResponse {
518            event_id,
519            timestamp,
520            version: Some(new_version),
521        });
522    }
523
524    // Semi-sync/sync: wait for follower ACK(s) after all events are ingested
525    await_replication_ack(&state).await;
526
527    let ingested = ingested_events.len();
528    tracing::info!("Batch ingested {} events", ingested);
529
530    Ok(Json(IngestEventsBatchResponse {
531        total,
532        ingested,
533        events: ingested_events,
534    }))
535}
536
537/// Sort-order query parameter for `GET /api/v1/events/query`. Kept separate
538/// from `QueryEventsRequest` so ordering is purely an HTTP-layer concern and
539/// the internal query DTO (used by ~50 call sites) stays untouched.
540#[derive(Debug, Deserialize)]
541pub struct EventOrderParam {
542    /// `asc` (oldest first, the default) or `desc` (newest first).
543    pub order: Option<String>,
544}
545
546pub async fn query_events(
547    OptionalAuth(auth): OptionalAuth,
548    Query(req): Query<QueryEventsRequest>,
549    Query(order_param): Query<EventOrderParam>,
550    State(store): State<SharedStore>,
551) -> Result<Json<QueryEventsResponse>> {
552    let requested_limit = req.limit;
553    let queried_entity_id = req.entity_id.clone();
554
555    // Sort order. Default is ascending (oldest first) to preserve replay
556    // semantics for existing consumers. `order=desc` returns newest first,
557    // so `?entity_id=<id>&limit=1&order=desc` reliably yields the latest
558    // event for an entity (issue #177).
559    let descending = match order_param.order.as_deref() {
560        None => false,
561        Some(o) if o.eq_ignore_ascii_case("asc") => false,
562        Some(o) if o.eq_ignore_ascii_case("desc") => true,
563        Some(other) => {
564            return Err(crate::error::AllSourceError::InvalidInput(format!(
565                "invalid 'order' value '{other}': expected 'asc' or 'desc'"
566            )));
567        }
568    };
569
570    // Tenant resolution. Since Core is internal-only (bead t-0ff8), the only
571    // callers are Control Plane's delegation layer and other internal Fly
572    // services. Request param wins — that's what the gateway sets authoritatively
573    // from the authenticated caller's identity. Auth-context fallback is kept
574    // as a defense-in-depth for any internal caller that forgets to pass
575    // tenant_id but is authenticated (legacy; audit and remove once all
576    // internal callers are confirmed to set tenant_id explicitly).
577    let enforced_tenant = req
578        .tenant_id
579        .clone()
580        .or_else(|| auth.as_ref().map(|a| a.tenant_id().to_string()));
581
582    // Query without limit to get total count
583    let unlimited_req = QueryEventsRequest {
584        entity_id: req.entity_id,
585        event_type: req.event_type,
586        tenant_id: enforced_tenant,
587        as_of: req.as_of,
588        since: req.since,
589        until: req.until,
590        limit: None,
591        event_type_prefix: req.event_type_prefix,
592        payload_filter: req.payload_filter,
593    };
594    let mut all_events = store.query(&unlimited_req)?;
595    let total_count = all_events.len();
596
597    // store.query() returns events ascending by (timestamp, version).
598    // Reverse for `order=desc` so the limit below takes the newest events.
599    if descending {
600        all_events.reverse();
601    }
602
603    // Apply limit
604    let limited_events: Vec<Event> = if let Some(limit) = requested_limit {
605        all_events.into_iter().take(limit).collect()
606    } else {
607        all_events
608    };
609
610    let count = limited_events.len();
611    let has_more = count < total_count;
612    let events: Vec<EventDto> = limited_events.iter().map(EventDto::from).collect();
613
614    // Include entity_version only when filtering by a single entity_id
615    let entity_version = queried_entity_id
616        .as_deref()
617        .map(|eid| store.get_entity_version(eid));
618
619    tracing::debug!("Query returned {} events (total: {})", count, total_count);
620
621    Ok(Json(QueryEventsResponse {
622        events,
623        count,
624        total_count,
625        has_more,
626        entity_version,
627    }))
628}
629
630pub async fn list_entities(
631    State(store): State<SharedStore>,
632    Query(req): Query<ListEntitiesRequest>,
633) -> Result<Json<ListEntitiesResponse>> {
634    use std::collections::HashMap;
635
636    // Get all events matching the filters
637    let query_req = QueryEventsRequest {
638        entity_id: None,
639        event_type: None,
640        tenant_id: None,
641        as_of: None,
642        since: None,
643        until: None,
644        limit: None,
645        event_type_prefix: req.event_type_prefix,
646        payload_filter: req.payload_filter,
647    };
648    let events = store.query(&query_req)?;
649
650    // Group by entity_id
651    let mut entity_map: HashMap<String, Vec<&Event>> = HashMap::new();
652    for event in &events {
653        entity_map
654            .entry(event.entity_id().to_string())
655            .or_default()
656            .push(event);
657    }
658
659    // Sort direction by last-event time. Default is `desc` (newest activity
660    // first) to preserve the endpoint's long-standing behavior; `order=asc`
661    // returns oldest activity first (issue #178).
662    let ascending = match req.order.as_deref() {
663        None => false,
664        Some(o) if o.eq_ignore_ascii_case("desc") => false,
665        Some(o) if o.eq_ignore_ascii_case("asc") => true,
666        Some(other) => {
667            return Err(crate::error::AllSourceError::InvalidInput(format!(
668                "invalid 'order' value '{other}': expected 'asc' or 'desc'"
669            )));
670        }
671    };
672
673    // Build entity summaries, then sort by last-event time in the requested
674    // direction. Ties are broken by `entity_id` (always ascending) so the
675    // total order is deterministic — required for stable offset pagination.
676    let mut summaries: Vec<EntitySummary> = entity_map
677        .into_iter()
678        .map(|(entity_id, events)| {
679            let last = events.iter().max_by_key(|e| e.timestamp()).unwrap();
680            EntitySummary {
681                entity_id,
682                event_count: events.len(),
683                last_event_type: last.event_type_str().to_string(),
684                last_event_at: last.timestamp(),
685            }
686        })
687        .collect();
688    summaries.sort_by(|a, b| {
689        let by_time = a.last_event_at.cmp(&b.last_event_at);
690        let by_time = if ascending {
691            by_time
692        } else {
693            by_time.reverse()
694        };
695        by_time.then_with(|| a.entity_id.cmp(&b.entity_id))
696    });
697
698    let total = summaries.len();
699
700    // Apply offset and limit
701    let offset = req.offset.unwrap_or(0);
702    let summaries: Vec<EntitySummary> = summaries.into_iter().skip(offset).collect::<Vec<_>>();
703    let summaries = if let Some(limit) = req.limit {
704        let has_more = summaries.len() > limit;
705        let truncated: Vec<EntitySummary> = summaries.into_iter().take(limit).collect();
706        return Ok(Json(ListEntitiesResponse {
707            entities: truncated,
708            total,
709            has_more,
710        }));
711    } else {
712        summaries
713    };
714
715    Ok(Json(ListEntitiesResponse {
716        entities: summaries,
717        total,
718        has_more: false,
719    }))
720}
721
722pub async fn detect_duplicates(
723    State(store): State<SharedStore>,
724    Query(req): Query<DetectDuplicatesRequest>,
725) -> Result<Json<DetectDuplicatesResponse>> {
726    use std::collections::HashMap;
727
728    let group_by_fields: Vec<&str> = req.group_by.split(',').map(str::trim).collect();
729
730    // Query events scoped by the required prefix
731    let query_req = QueryEventsRequest {
732        entity_id: None,
733        event_type: None,
734        tenant_id: None,
735        as_of: None,
736        since: None,
737        until: None,
738        limit: None,
739        event_type_prefix: Some(req.event_type_prefix),
740        payload_filter: None,
741    };
742    let events = store.query(&query_req)?;
743
744    // For each entity, extract the latest event's payload fields specified by group_by
745    // Then group entities by those field values
746    let mut entity_latest: HashMap<String, &Event> = HashMap::new();
747    for event in &events {
748        let eid = event.entity_id().to_string();
749        entity_latest
750            .entry(eid)
751            .and_modify(|existing| {
752                if event.timestamp() > existing.timestamp() {
753                    *existing = event;
754                }
755            })
756            .or_insert(event);
757    }
758
759    // Group entities by their payload field values
760    let mut groups: HashMap<String, Vec<String>> = HashMap::new();
761    for (entity_id, event) in &entity_latest {
762        let payload = event.payload();
763        let mut key_parts = serde_json::Map::new();
764        for field in &group_by_fields {
765            let value = payload
766                .get(*field)
767                .cloned()
768                .unwrap_or(serde_json::Value::Null);
769            key_parts.insert((*field).to_string(), value);
770        }
771        let key_str = serde_json::to_string(&key_parts).unwrap_or_default();
772        groups.entry(key_str).or_default().push(entity_id.clone());
773    }
774
775    // Filter to groups with count > 1 (actual duplicates)
776    let mut duplicate_groups: Vec<DuplicateGroup> = groups
777        .into_iter()
778        .filter(|(_, ids)| ids.len() > 1)
779        .map(|(key_str, mut ids)| {
780            ids.sort();
781            let key: serde_json::Value =
782                serde_json::from_str(&key_str).unwrap_or(serde_json::Value::Null);
783            let count = ids.len();
784            DuplicateGroup {
785                key,
786                entity_ids: ids,
787                count,
788            }
789        })
790        .collect();
791
792    // Sort by count descending for consistent output
793    duplicate_groups.sort_by(|a, b| b.count.cmp(&a.count));
794
795    let total = duplicate_groups.len();
796
797    // Apply offset and limit
798    let offset = req.offset.unwrap_or(0);
799    let duplicate_groups: Vec<DuplicateGroup> = duplicate_groups.into_iter().skip(offset).collect();
800
801    if let Some(limit) = req.limit {
802        let has_more = duplicate_groups.len() > limit;
803        let truncated: Vec<DuplicateGroup> = duplicate_groups.into_iter().take(limit).collect();
804        return Ok(Json(DetectDuplicatesResponse {
805            duplicates: truncated,
806            total,
807            has_more,
808        }));
809    }
810
811    Ok(Json(DetectDuplicatesResponse {
812        duplicates: duplicate_groups,
813        total,
814        has_more: false,
815    }))
816}
817
818#[derive(Deserialize)]
819pub struct EntityStateParams {
820    as_of: Option<chrono::DateTime<chrono::Utc>>,
821}
822
823pub async fn get_entity_state(
824    State(store): State<SharedStore>,
825    Path(entity_id): Path<String>,
826    Query(params): Query<EntityStateParams>,
827) -> Result<Json<serde_json::Value>> {
828    let state = store.reconstruct_state(&entity_id, params.as_of)?;
829
830    tracing::info!("State reconstructed for entity: {}", entity_id);
831
832    Ok(Json(state))
833}
834
835pub async fn get_entity_snapshot(
836    State(store): State<SharedStore>,
837    Path(entity_id): Path<String>,
838) -> Result<Json<serde_json::Value>> {
839    let snapshot = store.get_snapshot(&entity_id)?;
840
841    tracing::debug!("Snapshot retrieved for entity: {}", entity_id);
842
843    Ok(Json(snapshot))
844}
845
846pub async fn get_stats(State(store): State<SharedStore>) -> impl IntoResponse {
847    let stats = store.stats();
848    Json(stats)
849}
850
851// v0.10: List all streams (entity_ids) in the event store
852/// Query parameters for listing streams
853#[derive(Debug, Deserialize)]
854pub struct ListStreamsParams {
855    /// Optional limit on number of streams to return
856    pub limit: Option<usize>,
857    /// Optional offset for pagination
858    pub offset: Option<usize>,
859}
860
861/// Response for listing streams
862#[derive(Debug, serde::Serialize)]
863pub struct ListStreamsResponse {
864    pub streams: Vec<StreamInfo>,
865    pub total: usize,
866}
867
868pub async fn list_streams(
869    State(store): State<SharedStore>,
870    Query(params): Query<ListStreamsParams>,
871) -> Json<ListStreamsResponse> {
872    let mut streams = store.list_streams();
873    let total = streams.len();
874
875    // Sort by last_event_at descending (most recent first)
876    streams.sort_by(|a, b| b.last_event_at.cmp(&a.last_event_at));
877
878    // Apply pagination
879    if let Some(offset) = params.offset {
880        if offset < streams.len() {
881            streams = streams[offset..].to_vec();
882        } else {
883            streams = vec![];
884        }
885    }
886
887    if let Some(limit) = params.limit {
888        streams.truncate(limit);
889    }
890
891    tracing::debug!("Listed {} streams (total: {})", streams.len(), total);
892
893    Json(ListStreamsResponse { streams, total })
894}
895
896// v0.10: List all event types in the event store
897/// Query parameters for listing event types
898#[derive(Debug, Deserialize)]
899pub struct ListEventTypesParams {
900    /// Optional limit on number of event types to return
901    pub limit: Option<usize>,
902    /// Optional offset for pagination
903    pub offset: Option<usize>,
904}
905
906/// Response for listing event types
907#[derive(Debug, serde::Serialize)]
908pub struct ListEventTypesResponse {
909    pub event_types: Vec<EventTypeInfo>,
910    pub total: usize,
911}
912
913pub async fn list_event_types(
914    State(store): State<SharedStore>,
915    Query(params): Query<ListEventTypesParams>,
916) -> Json<ListEventTypesResponse> {
917    let mut event_types = store.list_event_types();
918    let total = event_types.len();
919
920    // Sort by event_count descending (most used first)
921    event_types.sort_by(|a, b| b.event_count.cmp(&a.event_count));
922
923    // Apply pagination
924    if let Some(offset) = params.offset {
925        if offset < event_types.len() {
926            event_types = event_types[offset..].to_vec();
927        } else {
928            event_types = vec![];
929        }
930    }
931
932    if let Some(limit) = params.limit {
933        event_types.truncate(limit);
934    }
935
936    tracing::debug!(
937        "Listed {} event types (total: {})",
938        event_types.len(),
939        total
940    );
941
942    Json(ListEventTypesResponse { event_types, total })
943}
944
945// v0.2: WebSocket endpoint for real-time event streaming
946#[derive(Debug, Deserialize)]
947pub struct WebSocketParams {
948    pub consumer_id: Option<String>,
949}
950
951pub async fn events_websocket(
952    ws: WebSocketUpgrade,
953    State(store): State<SharedStore>,
954    Query(params): Query<WebSocketParams>,
955) -> Response {
956    let websocket_manager = store.websocket_manager();
957
958    ws.on_upgrade(move |socket| async move {
959        if let Some(consumer_id) = params.consumer_id {
960            websocket_manager
961                .handle_socket_with_consumer(socket, consumer_id, store)
962                .await;
963        } else {
964            websocket_manager.handle_socket(socket).await;
965        }
966    })
967}
968
969// v0.2: Event frequency analytics endpoint
970pub async fn analytics_frequency(
971    State(store): State<SharedStore>,
972    Query(req): Query<EventFrequencyRequest>,
973) -> Result<Json<EventFrequencyResponse>> {
974    let response = AnalyticsEngine::event_frequency(&store, &req)?;
975
976    tracing::debug!(
977        "Frequency analysis returned {} buckets",
978        response.buckets.len()
979    );
980
981    Ok(Json(response))
982}
983
984// v0.2: Statistical summary endpoint
985pub async fn analytics_summary(
986    State(store): State<SharedStore>,
987    Query(req): Query<StatsSummaryRequest>,
988) -> Result<Json<StatsSummaryResponse>> {
989    let response = AnalyticsEngine::stats_summary(&store, &req)?;
990
991    tracing::debug!(
992        "Stats summary: {} events across {} entities",
993        response.total_events,
994        response.unique_entities
995    );
996
997    Ok(Json(response))
998}
999
1000// v0.2: Event correlation analysis endpoint
1001pub async fn analytics_correlation(
1002    State(store): State<SharedStore>,
1003    Query(req): Query<CorrelationRequest>,
1004) -> Result<Json<CorrelationResponse>> {
1005    let response = AnalyticsEngine::analyze_correlation(&store, req)?;
1006
1007    tracing::debug!(
1008        "Correlation analysis: {}/{} correlated pairs ({:.2}%)",
1009        response.correlated_pairs,
1010        response.total_a,
1011        response.correlation_percentage
1012    );
1013
1014    Ok(Json(response))
1015}
1016
1017// v0.2: Create a snapshot for an entity
1018pub async fn create_snapshot(
1019    State(store): State<SharedStore>,
1020    Json(req): Json<CreateSnapshotRequest>,
1021) -> Result<Json<CreateSnapshotResponse>> {
1022    store.create_snapshot(&req.entity_id)?;
1023
1024    let snapshot_manager = store.snapshot_manager();
1025    let snapshot = snapshot_manager
1026        .get_latest_snapshot(&req.entity_id)
1027        .ok_or_else(|| crate::error::AllSourceError::EntityNotFound(req.entity_id.clone()))?;
1028
1029    tracing::info!("📸 Created snapshot for entity: {}", req.entity_id);
1030
1031    Ok(Json(CreateSnapshotResponse {
1032        snapshot_id: snapshot.id,
1033        entity_id: snapshot.entity_id,
1034        created_at: snapshot.created_at,
1035        event_count: snapshot.event_count,
1036        size_bytes: snapshot.metadata.size_bytes,
1037    }))
1038}
1039
1040// v0.2: List snapshots
1041pub async fn list_snapshots(
1042    State(store): State<SharedStore>,
1043    Query(req): Query<ListSnapshotsRequest>,
1044) -> Result<Json<ListSnapshotsResponse>> {
1045    let snapshot_manager = store.snapshot_manager();
1046
1047    let snapshots: Vec<SnapshotInfo> = if let Some(entity_id) = req.entity_id {
1048        snapshot_manager
1049            .get_all_snapshots(&entity_id)
1050            .into_iter()
1051            .map(SnapshotInfo::from)
1052            .collect()
1053    } else {
1054        // List all entities with snapshots
1055        let entities = snapshot_manager.list_entities();
1056        entities
1057            .iter()
1058            .flat_map(|entity_id| {
1059                snapshot_manager
1060                    .get_all_snapshots(entity_id)
1061                    .into_iter()
1062                    .map(SnapshotInfo::from)
1063            })
1064            .collect()
1065    };
1066
1067    let total = snapshots.len();
1068
1069    tracing::debug!("Listed {} snapshots", total);
1070
1071    Ok(Json(ListSnapshotsResponse { snapshots, total }))
1072}
1073
1074// v0.2: Get latest snapshot for an entity
1075pub async fn get_latest_snapshot(
1076    State(store): State<SharedStore>,
1077    Path(entity_id): Path<String>,
1078) -> Result<Json<serde_json::Value>> {
1079    let snapshot_manager = store.snapshot_manager();
1080
1081    let snapshot = snapshot_manager
1082        .get_latest_snapshot(&entity_id)
1083        .ok_or_else(|| crate::error::AllSourceError::EntityNotFound(entity_id.clone()))?;
1084
1085    tracing::debug!("Retrieved latest snapshot for entity: {}", entity_id);
1086
1087    Ok(Json(serde_json::json!({
1088        "snapshot_id": snapshot.id,
1089        "entity_id": snapshot.entity_id,
1090        "created_at": snapshot.created_at,
1091        "as_of": snapshot.as_of,
1092        "event_count": snapshot.event_count,
1093        "size_bytes": snapshot.metadata.size_bytes,
1094        "snapshot_type": snapshot.metadata.snapshot_type,
1095        "state": snapshot.state
1096    })))
1097}
1098
1099// v0.2: Trigger manual compaction
1100pub async fn trigger_compaction(
1101    State(store): State<SharedStore>,
1102) -> Result<Json<CompactionResult>> {
1103    let compaction_manager = store.compaction_manager().ok_or_else(|| {
1104        crate::error::AllSourceError::InternalError(
1105            "Compaction not enabled (no Parquet storage)".to_string(),
1106        )
1107    })?;
1108
1109    tracing::info!("📦 Manual compaction triggered via API");
1110
1111    let result = compaction_manager.compact_now()?;
1112
1113    Ok(Json(result))
1114}
1115
1116// v0.2: Get compaction statistics
1117pub async fn compaction_stats(State(store): State<SharedStore>) -> Result<Json<serde_json::Value>> {
1118    let compaction_manager = store.compaction_manager().ok_or_else(|| {
1119        crate::error::AllSourceError::InternalError(
1120            "Compaction not enabled (no Parquet storage)".to_string(),
1121        )
1122    })?;
1123
1124    let stats = compaction_manager.stats();
1125    let config = compaction_manager.config();
1126
1127    Ok(Json(serde_json::json!({
1128        "stats": stats,
1129        "config": {
1130            "min_files_to_compact": config.min_files_to_compact,
1131            "target_file_size": config.target_file_size,
1132            "max_file_size": config.max_file_size,
1133            "small_file_threshold": config.small_file_threshold,
1134            "compaction_interval_seconds": config.compaction_interval_seconds,
1135            "auto_compact": config.auto_compact,
1136            "strategy": config.strategy
1137        }
1138    })))
1139}
1140
1141// v0.5: Register a new schema
1142pub async fn register_schema(
1143    State(store): State<SharedStore>,
1144    Json(req): Json<RegisterSchemaRequest>,
1145) -> Result<Json<RegisterSchemaResponse>> {
1146    let schema_registry = store.schema_registry();
1147
1148    let response =
1149        schema_registry.register_schema(req.subject, req.schema, req.description, req.tags)?;
1150
1151    tracing::info!(
1152        "📋 Schema registered: v{} for '{}'",
1153        response.version,
1154        response.subject
1155    );
1156
1157    Ok(Json(response))
1158}
1159
1160// v0.5: Get a schema by subject and optional version
1161#[derive(Deserialize)]
1162pub struct GetSchemaParams {
1163    version: Option<u32>,
1164}
1165
1166pub async fn get_schema(
1167    State(store): State<SharedStore>,
1168    Path(subject): Path<String>,
1169    Query(params): Query<GetSchemaParams>,
1170) -> Result<Json<serde_json::Value>> {
1171    let schema_registry = store.schema_registry();
1172
1173    let schema = schema_registry.get_schema(&subject, params.version)?;
1174
1175    tracing::debug!("Retrieved schema v{} for '{}'", schema.version, subject);
1176
1177    Ok(Json(serde_json::json!({
1178        "id": schema.id,
1179        "subject": schema.subject,
1180        "version": schema.version,
1181        "schema": schema.schema,
1182        "created_at": schema.created_at,
1183        "description": schema.description,
1184        "tags": schema.tags
1185    })))
1186}
1187
1188// v0.5: List all versions of a schema subject
1189pub async fn list_schema_versions(
1190    State(store): State<SharedStore>,
1191    Path(subject): Path<String>,
1192) -> Result<Json<serde_json::Value>> {
1193    let schema_registry = store.schema_registry();
1194
1195    let versions = schema_registry.list_versions(&subject)?;
1196
1197    Ok(Json(serde_json::json!({
1198        "subject": subject,
1199        "versions": versions
1200    })))
1201}
1202
1203// v0.5: List all schema subjects
1204pub async fn list_subjects(State(store): State<SharedStore>) -> Json<serde_json::Value> {
1205    let schema_registry = store.schema_registry();
1206
1207    let subjects = schema_registry.list_subjects();
1208
1209    Json(serde_json::json!({
1210        "subjects": subjects,
1211        "total": subjects.len()
1212    }))
1213}
1214
1215// v0.5: Validate an event against a schema
1216pub async fn validate_event_schema(
1217    State(store): State<SharedStore>,
1218    Json(req): Json<ValidateEventRequest>,
1219) -> Result<Json<ValidateEventResponse>> {
1220    let schema_registry = store.schema_registry();
1221
1222    let response = schema_registry.validate(&req.subject, req.version, &req.payload)?;
1223
1224    if response.valid {
1225        tracing::debug!(
1226            "✅ Event validated against schema '{}' v{}",
1227            req.subject,
1228            response.schema_version
1229        );
1230    } else {
1231        tracing::warn!(
1232            "❌ Event validation failed for '{}': {:?}",
1233            req.subject,
1234            response.errors
1235        );
1236    }
1237
1238    Ok(Json(response))
1239}
1240
1241// v0.5: Set compatibility mode for a subject
1242#[derive(Deserialize)]
1243pub struct SetCompatibilityRequest {
1244    compatibility: CompatibilityMode,
1245}
1246
1247pub async fn set_compatibility_mode(
1248    State(store): State<SharedStore>,
1249    Path(subject): Path<String>,
1250    Json(req): Json<SetCompatibilityRequest>,
1251) -> Json<serde_json::Value> {
1252    let schema_registry = store.schema_registry();
1253
1254    schema_registry.set_compatibility_mode(subject.clone(), req.compatibility);
1255
1256    tracing::info!(
1257        "🔧 Set compatibility mode for '{}' to {:?}",
1258        subject,
1259        req.compatibility
1260    );
1261
1262    Json(serde_json::json!({
1263        "subject": subject,
1264        "compatibility": req.compatibility
1265    }))
1266}
1267
1268// v0.5: Start a replay operation
1269pub async fn start_replay(
1270    State(store): State<SharedStore>,
1271    Json(req): Json<StartReplayRequest>,
1272) -> Result<Json<StartReplayResponse>> {
1273    let replay_manager = store.replay_manager();
1274
1275    let response = replay_manager.start_replay(store, req)?;
1276
1277    tracing::info!(
1278        "🔄 Started replay {} with {} events",
1279        response.replay_id,
1280        response.total_events
1281    );
1282
1283    Ok(Json(response))
1284}
1285
1286// v0.5: Get replay progress
1287pub async fn get_replay_progress(
1288    State(store): State<SharedStore>,
1289    Path(replay_id): Path<uuid::Uuid>,
1290) -> Result<Json<ReplayProgress>> {
1291    let replay_manager = store.replay_manager();
1292
1293    let progress = replay_manager.get_progress(replay_id)?;
1294
1295    Ok(Json(progress))
1296}
1297
1298// v0.5: List all replay operations
1299pub async fn list_replays(State(store): State<SharedStore>) -> Json<serde_json::Value> {
1300    let replay_manager = store.replay_manager();
1301
1302    let replays = replay_manager.list_replays();
1303
1304    Json(serde_json::json!({
1305        "replays": replays,
1306        "total": replays.len()
1307    }))
1308}
1309
1310// v0.5: Cancel a running replay
1311pub async fn cancel_replay(
1312    State(store): State<SharedStore>,
1313    Path(replay_id): Path<uuid::Uuid>,
1314) -> Result<Json<serde_json::Value>> {
1315    let replay_manager = store.replay_manager();
1316
1317    replay_manager.cancel_replay(replay_id)?;
1318
1319    tracing::info!("🛑 Cancelled replay {}", replay_id);
1320
1321    Ok(Json(serde_json::json!({
1322        "replay_id": replay_id,
1323        "status": "cancelled"
1324    })))
1325}
1326
1327// v0.5: Delete a completed replay
1328pub async fn delete_replay(
1329    State(store): State<SharedStore>,
1330    Path(replay_id): Path<uuid::Uuid>,
1331) -> Result<Json<serde_json::Value>> {
1332    let replay_manager = store.replay_manager();
1333
1334    let deleted = replay_manager.delete_replay(replay_id)?;
1335
1336    if deleted {
1337        tracing::info!("🗑️  Deleted replay {}", replay_id);
1338    }
1339
1340    Ok(Json(serde_json::json!({
1341        "replay_id": replay_id,
1342        "deleted": deleted
1343    })))
1344}
1345
1346// v0.5: Register a new pipeline
1347pub async fn register_pipeline(
1348    State(store): State<SharedStore>,
1349    Json(config): Json<PipelineConfig>,
1350) -> Result<Json<serde_json::Value>> {
1351    let pipeline_manager = store.pipeline_manager();
1352
1353    let pipeline_id = pipeline_manager.register(config.clone());
1354
1355    tracing::info!(
1356        "🔀 Pipeline registered: {} (name: {})",
1357        pipeline_id,
1358        config.name
1359    );
1360
1361    Ok(Json(serde_json::json!({
1362        "pipeline_id": pipeline_id,
1363        "name": config.name,
1364        "enabled": config.enabled
1365    })))
1366}
1367
1368// v0.5: List all pipelines
1369pub async fn list_pipelines(State(store): State<SharedStore>) -> Json<serde_json::Value> {
1370    let pipeline_manager = store.pipeline_manager();
1371
1372    let pipelines = pipeline_manager.list();
1373
1374    tracing::debug!("Listed {} pipelines", pipelines.len());
1375
1376    Json(serde_json::json!({
1377        "pipelines": pipelines,
1378        "total": pipelines.len()
1379    }))
1380}
1381
1382// v0.5: Get a specific pipeline
1383pub async fn get_pipeline(
1384    State(store): State<SharedStore>,
1385    Path(pipeline_id): Path<uuid::Uuid>,
1386) -> Result<Json<PipelineConfig>> {
1387    let pipeline_manager = store.pipeline_manager();
1388
1389    let pipeline = pipeline_manager.get(pipeline_id).ok_or_else(|| {
1390        crate::error::AllSourceError::ValidationError(format!("Pipeline not found: {pipeline_id}"))
1391    })?;
1392
1393    Ok(Json(pipeline.config().clone()))
1394}
1395
1396// v0.5: Remove a pipeline
1397pub async fn remove_pipeline(
1398    State(store): State<SharedStore>,
1399    Path(pipeline_id): Path<uuid::Uuid>,
1400) -> Result<Json<serde_json::Value>> {
1401    let pipeline_manager = store.pipeline_manager();
1402
1403    let removed = pipeline_manager.remove(pipeline_id);
1404
1405    if removed {
1406        tracing::info!("🗑️  Removed pipeline {}", pipeline_id);
1407    }
1408
1409    Ok(Json(serde_json::json!({
1410        "pipeline_id": pipeline_id,
1411        "removed": removed
1412    })))
1413}
1414
1415// v0.5: Get statistics for all pipelines
1416pub async fn all_pipeline_stats(State(store): State<SharedStore>) -> Json<serde_json::Value> {
1417    let pipeline_manager = store.pipeline_manager();
1418
1419    let stats = pipeline_manager.all_stats();
1420
1421    Json(serde_json::json!({
1422        "stats": stats,
1423        "total": stats.len()
1424    }))
1425}
1426
1427// v0.5: Get statistics for a specific pipeline
1428pub async fn get_pipeline_stats(
1429    State(store): State<SharedStore>,
1430    Path(pipeline_id): Path<uuid::Uuid>,
1431) -> Result<Json<PipelineStats>> {
1432    let pipeline_manager = store.pipeline_manager();
1433
1434    let pipeline = pipeline_manager.get(pipeline_id).ok_or_else(|| {
1435        crate::error::AllSourceError::ValidationError(format!("Pipeline not found: {pipeline_id}"))
1436    })?;
1437
1438    Ok(Json(pipeline.stats()))
1439}
1440
1441// v0.5: Reset a pipeline's state
1442pub async fn reset_pipeline(
1443    State(store): State<SharedStore>,
1444    Path(pipeline_id): Path<uuid::Uuid>,
1445) -> Result<Json<serde_json::Value>> {
1446    let pipeline_manager = store.pipeline_manager();
1447
1448    let pipeline = pipeline_manager.get(pipeline_id).ok_or_else(|| {
1449        crate::error::AllSourceError::ValidationError(format!("Pipeline not found: {pipeline_id}"))
1450    })?;
1451
1452    pipeline.reset();
1453
1454    tracing::info!("🔄 Reset pipeline {}", pipeline_id);
1455
1456    Ok(Json(serde_json::json!({
1457        "pipeline_id": pipeline_id,
1458        "reset": true
1459    })))
1460}
1461
1462// =============================================================================
1463// v0.11: Single Event Lookup by ID
1464// =============================================================================
1465
1466/// Get a single event by UUID
1467pub async fn get_event_by_id(
1468    State(store): State<SharedStore>,
1469    Path(event_id): Path<uuid::Uuid>,
1470) -> Result<Json<serde_json::Value>> {
1471    let event = store.get_event_by_id(&event_id)?.ok_or_else(|| {
1472        crate::error::AllSourceError::EntityNotFound(format!("Event '{event_id}' not found"))
1473    })?;
1474
1475    let dto = EventDto::from(&event);
1476
1477    tracing::debug!("Event retrieved by ID: {}", event_id);
1478
1479    Ok(Json(serde_json::json!({
1480        "event": dto,
1481        "found": true
1482    })))
1483}
1484
1485// =============================================================================
1486// v0.7: Projection State API for Query Service Integration
1487// =============================================================================
1488
1489/// List all registered projections
1490pub async fn list_projections(State(store): State<SharedStore>) -> Json<serde_json::Value> {
1491    let projection_manager = store.projection_manager();
1492    let status_map = store.projection_status();
1493
1494    let projections: Vec<serde_json::Value> = projection_manager
1495        .list_projections()
1496        .iter()
1497        .map(|(name, projection)| {
1498            let status = status_map
1499                .get(name)
1500                .map_or_else(|| "running".to_string(), |s| s.value().clone());
1501            serde_json::json!({
1502                "name": name,
1503                "type": format!("{:?}", projection.name()),
1504                "status": status,
1505            })
1506        })
1507        .collect();
1508
1509    tracing::debug!("Listed {} projections", projections.len());
1510
1511    Json(serde_json::json!({
1512        "projections": projections,
1513        "total": projections.len()
1514    }))
1515}
1516
1517/// Get projection metadata by name
1518pub async fn get_projection(
1519    State(store): State<SharedStore>,
1520    Path(name): Path<String>,
1521) -> Result<Json<serde_json::Value>> {
1522    let projection_manager = store.projection_manager();
1523
1524    let projection = projection_manager.get_projection(&name).ok_or_else(|| {
1525        crate::error::AllSourceError::EntityNotFound(format!("Projection '{name}' not found"))
1526    })?;
1527
1528    Ok(Json(serde_json::json!({
1529        "name": projection.name(),
1530        "found": true
1531    })))
1532}
1533
1534/// Get projection state for a specific entity.
1535///
1536/// Resolution order:
1537/// 1. **Registered projection** — if `name` is registered with the projection
1538///    manager, return the projection's own `get_state(entity_id)` output.
1539/// 2. **Projection state cache** — otherwise fall back to whatever was written
1540///    via `save_projection_state` / `bulk_save_projection_states`. This supports
1541///    SDK-managed projections (e.g. the Rust SDK's `ProjectionWorker`) that
1542///    compute state client-side and push it back without registering a
1543///    projection in Core's manager.
1544///
1545/// Returns `found: false` with `state: null` when neither source has state.
1546pub async fn get_projection_state(
1547    State(store): State<SharedStore>,
1548    Path((name, entity_id)): Path<(String, String)>,
1549) -> Result<Json<serde_json::Value>> {
1550    let state = store
1551        .projection_manager()
1552        .get_projection(&name)
1553        .and_then(|p| p.get_state(&entity_id))
1554        .or_else(|| {
1555            store
1556                .projection_state_cache()
1557                .get(&format!("{name}:{entity_id}"))
1558                .map(|entry| entry.value().clone())
1559        });
1560
1561    tracing::debug!("Projection state retrieved: {} / {}", name, entity_id);
1562
1563    Ok(Json(serde_json::json!({
1564        "projection": name,
1565        "entity_id": entity_id,
1566        "state": state,
1567        "found": state.is_some()
1568    })))
1569}
1570
1571/// Delete (clear) a projection by name
1572///
1573/// Removes all state from the projection. The projection definition remains
1574/// registered but its accumulated state is cleared.
1575pub async fn delete_projection(
1576    State(store): State<SharedStore>,
1577    Path(name): Path<String>,
1578) -> Result<Json<serde_json::Value>> {
1579    let projection_manager = store.projection_manager();
1580
1581    let projection = projection_manager.get_projection(&name).ok_or_else(|| {
1582        crate::error::AllSourceError::EntityNotFound(format!("Projection '{name}' not found"))
1583    })?;
1584
1585    projection.clear();
1586
1587    // Also clear any cached state for this projection
1588    let cache = store.projection_state_cache();
1589    let prefix = format!("{name}:");
1590    let keys_to_remove: Vec<String> = cache
1591        .iter()
1592        .filter(|entry| entry.key().starts_with(&prefix))
1593        .map(|entry| entry.key().clone())
1594        .collect();
1595    for key in keys_to_remove {
1596        cache.remove(&key);
1597    }
1598
1599    tracing::info!("Projection deleted (cleared): {}", name);
1600
1601    Ok(Json(serde_json::json!({
1602        "projection": name,
1603        "deleted": true
1604    })))
1605}
1606
1607/// Get aggregate projection state (all entities).
1608///
1609/// Returns the cached states written via `save_projection_state` /
1610/// `bulk_save_projection_states`. The projection does NOT need to be
1611/// registered with the projection manager — this supports SDK-managed
1612/// projections that push state without server-side registration.
1613///
1614/// Returns an empty list when no state has been written.
1615pub async fn get_projection_state_summary(
1616    State(store): State<SharedStore>,
1617    Path(name): Path<String>,
1618) -> Result<Json<serde_json::Value>> {
1619    let cache = store.projection_state_cache();
1620    let prefix = format!("{name}:");
1621    let states: Vec<serde_json::Value> = cache
1622        .iter()
1623        .filter(|entry| entry.key().starts_with(&prefix))
1624        .map(|entry| {
1625            let entity_id = entry.key().strip_prefix(&prefix).unwrap_or(entry.key());
1626            serde_json::json!({
1627                "entity_id": entity_id,
1628                "state": entry.value().clone()
1629            })
1630        })
1631        .collect();
1632
1633    let total = states.len();
1634
1635    tracing::debug!("Projection state summary: {} ({} entities)", name, total);
1636
1637    Ok(Json(serde_json::json!({
1638        "projection": name,
1639        "states": states,
1640        "total": total
1641    })))
1642}
1643
1644/// Reset a projection to its initial state
1645///
1646/// Clears all accumulated state and reprocesses events from the beginning.
1647pub async fn reset_projection(
1648    State(store): State<SharedStore>,
1649    Path(name): Path<String>,
1650) -> Result<Json<serde_json::Value>> {
1651    let reprocessed = store.reset_projection(&name)?;
1652
1653    tracing::info!(
1654        "Projection reset: {} ({} events reprocessed)",
1655        name,
1656        reprocessed
1657    );
1658
1659    Ok(Json(serde_json::json!({
1660        "projection": name,
1661        "reset": true,
1662        "events_reprocessed": reprocessed
1663    })))
1664}
1665
1666/// Pause a projection
1667///
1668/// Sets the projection status to "paused" so it stops processing new events.
1669pub async fn pause_projection(
1670    State(store): State<SharedStore>,
1671    Path(name): Path<String>,
1672) -> Result<Json<serde_json::Value>> {
1673    let projection_manager = store.projection_manager();
1674
1675    // Verify projection exists
1676    let _projection = projection_manager.get_projection(&name).ok_or_else(|| {
1677        crate::error::AllSourceError::EntityNotFound(format!("Projection '{name}' not found"))
1678    })?;
1679
1680    store
1681        .projection_status()
1682        .insert(name.clone(), "paused".to_string());
1683
1684    tracing::info!("Projection paused: {}", name);
1685
1686    Ok(Json(serde_json::json!({
1687        "projection": name,
1688        "status": "paused"
1689    })))
1690}
1691
1692/// Start (resume) a projection
1693///
1694/// Sets the projection status to "running" so it resumes processing events.
1695pub async fn start_projection(
1696    State(store): State<SharedStore>,
1697    Path(name): Path<String>,
1698) -> Result<Json<serde_json::Value>> {
1699    let projection_manager = store.projection_manager();
1700
1701    // Verify projection exists
1702    let _projection = projection_manager.get_projection(&name).ok_or_else(|| {
1703        crate::error::AllSourceError::EntityNotFound(format!("Projection '{name}' not found"))
1704    })?;
1705
1706    store
1707        .projection_status()
1708        .insert(name.clone(), "running".to_string());
1709
1710    tracing::info!("Projection started: {}", name);
1711
1712    Ok(Json(serde_json::json!({
1713        "projection": name,
1714        "status": "running"
1715    })))
1716}
1717
1718/// Request body for saving projection state
1719#[derive(Debug, Deserialize)]
1720pub struct SaveProjectionStateRequest {
1721    pub state: serde_json::Value,
1722}
1723
1724/// Save/update projection state for an entity
1725///
1726/// This endpoint allows external services (like Elixir Query Service) to
1727/// store computed projection state back to the Core for persistence.
1728pub async fn save_projection_state(
1729    State(store): State<SharedStore>,
1730    Path((name, entity_id)): Path<(String, String)>,
1731    Json(req): Json<SaveProjectionStateRequest>,
1732) -> Result<Json<serde_json::Value>> {
1733    let projection_cache = store.projection_state_cache();
1734
1735    // Store in the projection state cache
1736    projection_cache.insert(format!("{name}:{entity_id}"), req.state.clone());
1737
1738    tracing::info!("Projection state saved: {} / {}", name, entity_id);
1739
1740    Ok(Json(serde_json::json!({
1741        "projection": name,
1742        "entity_id": entity_id,
1743        "saved": true
1744    })))
1745}
1746
1747/// Bulk get projection states for multiple entities
1748///
1749/// Efficient endpoint for fetching multiple entity states in a single request.
1750#[derive(Debug, Deserialize)]
1751pub struct BulkGetStateRequest {
1752    pub entity_ids: Vec<String>,
1753}
1754
1755/// Bulk save projection states for multiple entities
1756///
1757/// Efficient endpoint for saving multiple entity states in a single request.
1758#[derive(Debug, Deserialize)]
1759pub struct BulkSaveStateRequest {
1760    pub states: Vec<BulkSaveStateItem>,
1761}
1762
1763#[derive(Debug, Deserialize)]
1764pub struct BulkSaveStateItem {
1765    pub entity_id: String,
1766    pub state: serde_json::Value,
1767}
1768
1769pub async fn bulk_get_projection_states(
1770    State(store): State<SharedStore>,
1771    Path(name): Path<String>,
1772    Json(req): Json<BulkGetStateRequest>,
1773) -> Result<Json<serde_json::Value>> {
1774    // Same fallback rule as `get_projection_state`: registered projection
1775    // wins, cache is the fallback. This lets SDK-managed projections read
1776    // their pushed-back state without registering in Core's projection manager.
1777    let projection = store.projection_manager().get_projection(&name);
1778    let cache = store.projection_state_cache();
1779
1780    let states: Vec<serde_json::Value> = req
1781        .entity_ids
1782        .iter()
1783        .map(|entity_id| {
1784            let state = projection
1785                .as_ref()
1786                .and_then(|p| p.get_state(entity_id))
1787                .or_else(|| {
1788                    cache
1789                        .get(&format!("{name}:{entity_id}"))
1790                        .map(|entry| entry.value().clone())
1791                });
1792            serde_json::json!({
1793                "entity_id": entity_id,
1794                "state": state,
1795                "found": state.is_some()
1796            })
1797        })
1798        .collect();
1799
1800    tracing::debug!(
1801        "Bulk projection state retrieved: {} entities from {}",
1802        states.len(),
1803        name
1804    );
1805
1806    Ok(Json(serde_json::json!({
1807        "projection": name,
1808        "states": states,
1809        "total": states.len()
1810    })))
1811}
1812
1813/// Bulk save projection states for multiple entities
1814///
1815/// This endpoint allows efficient batch saving of projection states,
1816/// critical for high-throughput event processing pipelines.
1817pub async fn bulk_save_projection_states(
1818    State(store): State<SharedStore>,
1819    Path(name): Path<String>,
1820    Json(req): Json<BulkSaveStateRequest>,
1821) -> Result<Json<serde_json::Value>> {
1822    let projection_cache = store.projection_state_cache();
1823
1824    let mut saved_count = 0;
1825    for item in &req.states {
1826        projection_cache.insert(format!("{name}:{}", item.entity_id), item.state.clone());
1827        saved_count += 1;
1828    }
1829
1830    tracing::info!(
1831        "Bulk projection state saved: {} entities for {}",
1832        saved_count,
1833        name
1834    );
1835
1836    Ok(Json(serde_json::json!({
1837        "projection": name,
1838        "saved": saved_count,
1839        "total": req.states.len()
1840    })))
1841}
1842
1843// =============================================================================
1844// v0.11: Webhook Management API
1845// =============================================================================
1846
1847/// Query parameters for listing webhooks
1848#[derive(Debug, Deserialize)]
1849pub struct ListWebhooksParams {
1850    pub tenant_id: Option<String>,
1851}
1852
1853/// Register a new webhook subscription
1854pub async fn register_webhook(
1855    State(store): State<SharedStore>,
1856    Json(req): Json<RegisterWebhookRequest>,
1857) -> Json<serde_json::Value> {
1858    let registry = store.webhook_registry();
1859    let webhook = registry.register(req);
1860
1861    tracing::info!("Webhook registered: {} -> {}", webhook.id, webhook.url);
1862
1863    Json(serde_json::json!({
1864        "webhook": webhook,
1865        "created": true
1866    }))
1867}
1868
1869/// List webhooks, optionally filtered by tenant_id
1870pub async fn list_webhooks(
1871    State(store): State<SharedStore>,
1872    Query(params): Query<ListWebhooksParams>,
1873) -> Json<serde_json::Value> {
1874    let registry = store.webhook_registry();
1875
1876    let webhooks = if let Some(tenant_id) = params.tenant_id {
1877        registry.list_by_tenant(&tenant_id)
1878    } else {
1879        // Without tenant filter, return empty (tenants should always filter)
1880        vec![]
1881    };
1882
1883    let total = webhooks.len();
1884
1885    Json(serde_json::json!({
1886        "webhooks": webhooks,
1887        "total": total
1888    }))
1889}
1890
1891/// Get a specific webhook by ID
1892pub async fn get_webhook(
1893    State(store): State<SharedStore>,
1894    Path(webhook_id): Path<uuid::Uuid>,
1895) -> Result<Json<serde_json::Value>> {
1896    let registry = store.webhook_registry();
1897
1898    let webhook = registry.get(webhook_id).ok_or_else(|| {
1899        crate::error::AllSourceError::EntityNotFound(format!("Webhook '{webhook_id}' not found"))
1900    })?;
1901
1902    Ok(Json(serde_json::json!({
1903        "webhook": webhook,
1904        "found": true
1905    })))
1906}
1907
1908/// Update a webhook subscription
1909pub async fn update_webhook(
1910    State(store): State<SharedStore>,
1911    Path(webhook_id): Path<uuid::Uuid>,
1912    Json(req): Json<UpdateWebhookRequest>,
1913) -> Result<Json<serde_json::Value>> {
1914    let registry = store.webhook_registry();
1915
1916    let webhook = registry.update(webhook_id, req).ok_or_else(|| {
1917        crate::error::AllSourceError::EntityNotFound(format!("Webhook '{webhook_id}' not found"))
1918    })?;
1919
1920    tracing::info!("Webhook updated: {}", webhook_id);
1921
1922    Ok(Json(serde_json::json!({
1923        "webhook": webhook,
1924        "updated": true
1925    })))
1926}
1927
1928/// Delete a webhook subscription
1929pub async fn delete_webhook(
1930    State(store): State<SharedStore>,
1931    Path(webhook_id): Path<uuid::Uuid>,
1932) -> Result<Json<serde_json::Value>> {
1933    let registry = store.webhook_registry();
1934
1935    let webhook = registry.delete(webhook_id).ok_or_else(|| {
1936        crate::error::AllSourceError::EntityNotFound(format!("Webhook '{webhook_id}' not found"))
1937    })?;
1938
1939    tracing::info!("Webhook deleted: {} ({})", webhook_id, webhook.url);
1940
1941    Ok(Json(serde_json::json!({
1942        "webhook_id": webhook_id,
1943        "deleted": true
1944    })))
1945}
1946
1947/// Query parameters for listing webhook deliveries
1948#[derive(Debug, Deserialize)]
1949pub struct ListDeliveriesParams {
1950    pub limit: Option<usize>,
1951}
1952
1953/// List delivery history for a webhook
1954pub async fn list_webhook_deliveries(
1955    State(store): State<SharedStore>,
1956    Path(webhook_id): Path<uuid::Uuid>,
1957    Query(params): Query<ListDeliveriesParams>,
1958) -> Result<Json<serde_json::Value>> {
1959    let registry = store.webhook_registry();
1960
1961    // Verify webhook exists
1962    registry.get(webhook_id).ok_or_else(|| {
1963        crate::error::AllSourceError::EntityNotFound(format!("Webhook '{webhook_id}' not found"))
1964    })?;
1965
1966    let limit = params.limit.unwrap_or(50);
1967    let deliveries = registry.get_deliveries(webhook_id, limit);
1968    let total = deliveries.len();
1969
1970    Ok(Json(serde_json::json!({
1971        "webhook_id": webhook_id,
1972        "deliveries": deliveries,
1973        "total": total
1974    })))
1975}
1976
1977// =============================================================================
1978// v2.0: Advanced Query Features
1979// =============================================================================
1980
1981/// EventQL: Execute SQL queries over events using DataFusion
1982#[cfg(feature = "analytics")]
1983pub async fn eventql_query(
1984    State(store): State<SharedStore>,
1985    Json(req): Json<crate::infrastructure::query::eventql::EventQLRequest>,
1986) -> Result<Json<serde_json::Value>> {
1987    let events = store.snapshot_events();
1988    match crate::infrastructure::query::eventql::execute_eventql(&events, &req).await {
1989        Ok(response) => Ok(Json(serde_json::json!({
1990            "columns": response.columns,
1991            "rows": response.rows,
1992            "row_count": response.row_count,
1993        }))),
1994        Err(e) => Err(crate::error::AllSourceError::InvalidQuery(e)),
1995    }
1996}
1997
1998/// GraphQL: Execute GraphQL queries
1999pub async fn graphql_query(
2000    State(store): State<SharedStore>,
2001    Json(req): Json<GraphQLRequest>,
2002) -> Json<serde_json::Value> {
2003    let fields = match crate::infrastructure::query::graphql::parse_query(&req.query) {
2004        Ok(f) => f,
2005        Err(e) => {
2006            return Json(
2007                serde_json::to_value(GraphQLResponse {
2008                    data: None,
2009                    errors: vec![GraphQLError { message: e }],
2010                })
2011                .unwrap(),
2012            );
2013        }
2014    };
2015
2016    let mut data = serde_json::Map::new();
2017    let mut errors = Vec::new();
2018
2019    for field in &fields {
2020        match field.name.as_str() {
2021            "events" => {
2022                let request = crate::application::dto::QueryEventsRequest {
2023                    entity_id: field.arguments.get("entity_id").cloned(),
2024                    event_type: field.arguments.get("event_type").cloned(),
2025                    tenant_id: field.arguments.get("tenant_id").cloned(),
2026                    limit: field.arguments.get("limit").and_then(|l| l.parse().ok()),
2027                    as_of: None,
2028                    since: None,
2029                    until: None,
2030                    event_type_prefix: None,
2031                    payload_filter: None,
2032                };
2033                match store.query(&request) {
2034                    Ok(events) => {
2035                        let json_events: Vec<serde_json::Value> = events
2036                            .iter()
2037                            .map(|e| {
2038                                crate::infrastructure::query::graphql::event_to_json(
2039                                    e,
2040                                    &field.fields,
2041                                )
2042                            })
2043                            .collect();
2044                        data.insert("events".to_string(), serde_json::Value::Array(json_events));
2045                    }
2046                    Err(e) => errors.push(GraphQLError {
2047                        message: format!("events query failed: {e}"),
2048                    }),
2049                }
2050            }
2051            "event" => {
2052                if let Some(id_str) = field.arguments.get("id") {
2053                    if let Ok(id) = uuid::Uuid::parse_str(id_str) {
2054                        match store.get_event_by_id(&id) {
2055                            Ok(Some(event)) => {
2056                                data.insert(
2057                                    "event".to_string(),
2058                                    crate::infrastructure::query::graphql::event_to_json(
2059                                        &event,
2060                                        &field.fields,
2061                                    ),
2062                                );
2063                            }
2064                            Ok(None) => {
2065                                data.insert("event".to_string(), serde_json::Value::Null);
2066                            }
2067                            Err(e) => errors.push(GraphQLError {
2068                                message: format!("event lookup failed: {e}"),
2069                            }),
2070                        }
2071                    } else {
2072                        errors.push(GraphQLError {
2073                            message: format!("Invalid UUID: {id_str}"),
2074                        });
2075                    }
2076                } else {
2077                    errors.push(GraphQLError {
2078                        message: "event query requires 'id' argument".to_string(),
2079                    });
2080                }
2081            }
2082            "projections" => {
2083                let pm = store.projection_manager();
2084                let names: Vec<serde_json::Value> = pm
2085                    .list_projections()
2086                    .iter()
2087                    .map(|(name, _)| serde_json::Value::String(name.clone()))
2088                    .collect();
2089                data.insert("projections".to_string(), serde_json::Value::Array(names));
2090            }
2091            "stats" => {
2092                let stats = store.stats();
2093                data.insert(
2094                    "stats".to_string(),
2095                    serde_json::json!({
2096                        "total_events": stats.total_events,
2097                        "total_entities": stats.total_entities,
2098                        "total_event_types": stats.total_event_types,
2099                    }),
2100                );
2101            }
2102            "__schema" => {
2103                data.insert(
2104                    "__schema".to_string(),
2105                    crate::infrastructure::query::graphql::introspection_schema(),
2106                );
2107            }
2108            other => {
2109                errors.push(GraphQLError {
2110                    message: format!("Unknown field: {other}"),
2111                });
2112            }
2113        }
2114    }
2115
2116    Json(
2117        serde_json::to_value(GraphQLResponse {
2118            data: Some(serde_json::Value::Object(data)),
2119            errors,
2120        })
2121        .unwrap(),
2122    )
2123}
2124
2125/// Geospatial: Query events by location
2126pub async fn geo_query(
2127    State(store): State<SharedStore>,
2128    Json(req): Json<GeoQueryRequest>,
2129) -> Json<serde_json::Value> {
2130    let events = store.snapshot_events();
2131    let geo_index = store.geo_index();
2132    let results =
2133        crate::infrastructure::query::geospatial::execute_geo_query(&events, &geo_index, &req);
2134    let total = results.len();
2135    Json(serde_json::json!({
2136        "results": results,
2137        "total": total,
2138    }))
2139}
2140
2141/// Geospatial index stats
2142pub async fn geo_stats(State(store): State<SharedStore>) -> Json<serde_json::Value> {
2143    let stats = store.geo_index().stats();
2144    Json(serde_json::json!(stats))
2145}
2146
2147/// Exactly-once processing stats
2148pub async fn exactly_once_stats(State(store): State<SharedStore>) -> Json<serde_json::Value> {
2149    let stats = store.exactly_once().stats();
2150    Json(serde_json::json!(stats))
2151}
2152
2153/// Schema evolution history for an event type
2154pub async fn schema_evolution_history(
2155    State(store): State<SharedStore>,
2156    Path(event_type): Path<String>,
2157) -> Json<serde_json::Value> {
2158    let mgr = store.schema_evolution();
2159    let history = mgr.get_history(&event_type);
2160    let version = mgr.get_version(&event_type);
2161    Json(serde_json::json!({
2162        "event_type": event_type,
2163        "current_version": version,
2164        "history": history,
2165    }))
2166}
2167
2168/// Current inferred schema for an event type
2169pub async fn schema_evolution_schema(
2170    State(store): State<SharedStore>,
2171    Path(event_type): Path<String>,
2172) -> Json<serde_json::Value> {
2173    let mgr = store.schema_evolution();
2174    if let Some(schema) = mgr.get_schema(&event_type) {
2175        let json_schema = crate::application::services::schema_evolution::to_json_schema(&schema);
2176        Json(serde_json::json!({
2177            "event_type": event_type,
2178            "version": mgr.get_version(&event_type),
2179            "inferred_schema": schema,
2180            "json_schema": json_schema,
2181        }))
2182    } else {
2183        Json(serde_json::json!({
2184            "event_type": event_type,
2185            "error": "No schema inferred for this event type"
2186        }))
2187    }
2188}
2189
2190/// Schema evolution stats
2191pub async fn schema_evolution_stats(State(store): State<SharedStore>) -> Json<serde_json::Value> {
2192    let stats = store.schema_evolution().stats();
2193    let event_types = store.schema_evolution().list_event_types();
2194    Json(serde_json::json!({
2195        "stats": stats,
2196        "tracked_event_types": event_types,
2197    }))
2198}
2199
2200// =============================================================================
2201// Sync Protocol Endpoints (v0.11: embedded↔server bidirectional sync)
2202// =============================================================================
2203
2204/// POST /api/v1/sync/pull — Client sends version vector, server returns delta events.
2205#[cfg(feature = "embedded-sync")]
2206pub async fn sync_pull_handler(
2207    State(state): State<AppState>,
2208    Json(request): Json<crate::embedded::sync_types::SyncPullRequest>,
2209) -> Result<Json<crate::embedded::sync_types::SyncPullResponse>> {
2210    use crate::infrastructure::cluster::{crdt::ReplicatedEvent, hlc::HlcTimestamp};
2211
2212    let store = &state.store;
2213
2214    // Compute "since" threshold from the client's version vector
2215    // We return all events the client hasn't seen yet
2216    let since = request
2217        .version_vector
2218        .values()
2219        .map(|ts| ts.physical_ms)
2220        .min()
2221        .and_then(|ms| chrono::DateTime::from_timestamp_millis(ms as i64));
2222
2223    let events = store.query(&crate::application::dto::QueryEventsRequest {
2224        entity_id: None,
2225        event_type: None,
2226        tenant_id: None,
2227        as_of: None,
2228        since,
2229        until: None,
2230        limit: None,
2231        event_type_prefix: None,
2232        payload_filter: None,
2233    })?;
2234
2235    // Convert domain events to ReplicatedEvent wire format
2236    let mut replicated = Vec::with_capacity(events.len());
2237    let mut last_ms = 0u64;
2238    let mut logical = 0u32;
2239
2240    for event in &events {
2241        let event_ms = event.timestamp().timestamp_millis() as u64;
2242        if event_ms == last_ms {
2243            logical += 1;
2244        } else {
2245            last_ms = event_ms;
2246            logical = 0;
2247        }
2248
2249        replicated.push(ReplicatedEvent {
2250            event_id: event.id().to_string(),
2251            hlc_timestamp: HlcTimestamp::new(event_ms, logical, 0),
2252            origin_region: "server".to_string(),
2253            event_data: serde_json::json!({
2254                "event_type": event.event_type_str(),
2255                "entity_id": event.entity_id_str(),
2256                "tenant_id": event.tenant_id_str(),
2257                "payload": event.payload,
2258                "metadata": event.metadata,
2259            }),
2260        });
2261    }
2262
2263    Ok(Json(crate::embedded::sync_types::SyncPullResponse {
2264        events: replicated,
2265        version_vector: std::collections::BTreeMap::new(),
2266    }))
2267}
2268
2269/// POST /api/v1/sync/push — Client pushes events, server applies CRDT resolution.
2270#[cfg(feature = "embedded-sync")]
2271pub async fn sync_push_handler(
2272    State(state): State<AppState>,
2273    Json(request): Json<crate::embedded::sync_types::SyncPushRequest>,
2274) -> Result<Json<crate::embedded::sync_types::SyncPushResponse>> {
2275    let store = &state.store;
2276
2277    let mut accepted = 0usize;
2278    let mut skipped = 0usize;
2279
2280    for rep_event in &request.events {
2281        let event_data = &rep_event.event_data;
2282        let event_type = event_data
2283            .get("event_type")
2284            .and_then(|v| v.as_str())
2285            .unwrap_or("unknown")
2286            .to_string();
2287        let entity_id = event_data
2288            .get("entity_id")
2289            .and_then(|v| v.as_str())
2290            .unwrap_or("unknown")
2291            .to_string();
2292        let tenant_id = event_data
2293            .get("tenant_id")
2294            .and_then(|v| v.as_str())
2295            .unwrap_or("default")
2296            .to_string();
2297        let payload = event_data
2298            .get("payload")
2299            .cloned()
2300            .unwrap_or(serde_json::json!({}));
2301        let metadata = event_data.get("metadata").cloned();
2302
2303        match Event::from_strings(event_type, entity_id, tenant_id, payload, metadata) {
2304            Ok(domain_event) => {
2305                store.ingest(&domain_event)?;
2306                accepted += 1;
2307            }
2308            Err(_) => {
2309                skipped += 1;
2310            }
2311        }
2312    }
2313
2314    Ok(Json(crate::embedded::sync_types::SyncPushResponse {
2315        accepted,
2316        skipped,
2317        version_vector: std::collections::BTreeMap::new(),
2318    }))
2319}
2320
2321// =============================================================================
2322// Consumer endpoints for durable subscriptions (v0.14)
2323// =============================================================================
2324
2325/// POST /api/v1/consumers — Register a durable consumer
2326pub async fn register_consumer(
2327    State(store): State<SharedStore>,
2328    Json(req): Json<RegisterConsumerRequest>,
2329) -> Result<Json<ConsumerResponse>> {
2330    let consumer = store
2331        .consumer_registry()
2332        .register(&req.consumer_id, &req.event_type_filters);
2333
2334    Ok(Json(ConsumerResponse {
2335        consumer_id: consumer.consumer_id,
2336        event_type_filters: consumer.event_type_filters,
2337        cursor_position: consumer.cursor_position,
2338    }))
2339}
2340
2341/// GET /api/v1/consumers/{consumer_id} — Get consumer metadata and cursor position
2342pub async fn get_consumer(
2343    State(store): State<SharedStore>,
2344    Path(consumer_id): Path<String>,
2345) -> Result<Json<ConsumerResponse>> {
2346    let consumer = store.consumer_registry().get_or_create(&consumer_id);
2347
2348    Ok(Json(ConsumerResponse {
2349        consumer_id: consumer.consumer_id,
2350        event_type_filters: consumer.event_type_filters,
2351        cursor_position: consumer.cursor_position,
2352    }))
2353}
2354
2355/// GET /api/v1/consumers/{consumer_id}/events — Poll for events since last ack
2356#[derive(Debug, Deserialize)]
2357pub struct ConsumerPollQuery {
2358    pub limit: Option<usize>,
2359}
2360
2361pub async fn poll_consumer_events(
2362    State(store): State<SharedStore>,
2363    Path(consumer_id): Path<String>,
2364    Query(query): Query<ConsumerPollQuery>,
2365) -> Result<Json<ConsumerEventsResponse>> {
2366    let consumer = store.consumer_registry().get_or_create(&consumer_id);
2367    let offset = consumer.cursor_position.unwrap_or(0);
2368    let limit = query.limit.unwrap_or(100);
2369
2370    let events = store.events_after_offset(offset, &consumer.event_type_filters, limit);
2371    let count = events.len();
2372
2373    let consumer_events: Vec<ConsumerEventDto> = events
2374        .into_iter()
2375        .map(|(position, event)| ConsumerEventDto {
2376            position,
2377            event: EventDto::from(&event),
2378        })
2379        .collect();
2380
2381    Ok(Json(ConsumerEventsResponse {
2382        events: consumer_events,
2383        count,
2384    }))
2385}
2386
2387/// POST /api/v1/consumers/{consumer_id}/ack — Acknowledge processed events
2388pub async fn ack_consumer(
2389    State(store): State<SharedStore>,
2390    Path(consumer_id): Path<String>,
2391    Json(req): Json<AckRequest>,
2392) -> Result<Json<serde_json::Value>> {
2393    let max_offset = store.total_events() as u64;
2394
2395    store
2396        .consumer_registry()
2397        .ack(&consumer_id, req.position, max_offset)
2398        .map_err(crate::error::AllSourceError::InvalidInput)?;
2399
2400    Ok(Json(serde_json::json!({
2401        "status": "ok",
2402        "consumer_id": consumer_id,
2403        "position": req.position,
2404    })))
2405}
2406
2407#[cfg(test)]
2408mod tests {
2409    use super::*;
2410    use crate::{domain::entities::Event, store::EventStore};
2411
2412    fn create_test_store() -> Arc<EventStore> {
2413        Arc::new(EventStore::new())
2414    }
2415
2416    fn create_test_event(entity_id: &str, event_type: &str) -> Event {
2417        Event::from_strings(
2418            event_type.to_string(),
2419            entity_id.to_string(),
2420            "test-stream".to_string(),
2421            serde_json::json!({
2422                "name": "Test",
2423                "value": 42
2424            }),
2425            None,
2426        )
2427        .unwrap()
2428    }
2429
2430    #[tokio::test]
2431    async fn test_query_events_has_more_and_total_count() {
2432        let store = create_test_store();
2433
2434        // Ingest 50 events
2435        for i in 0..50 {
2436            store
2437                .ingest(&create_test_event(&format!("entity-{i}"), "user.created"))
2438                .unwrap();
2439        }
2440
2441        // Query with limit=10 — should get has_more=true, total_count=50
2442        let req = QueryEventsRequest {
2443            entity_id: None,
2444            event_type: None,
2445            tenant_id: None,
2446            as_of: None,
2447            since: None,
2448            until: None,
2449            limit: Some(10),
2450            event_type_prefix: None,
2451            payload_filter: None,
2452        };
2453
2454        let requested_limit = req.limit;
2455        let unlimited_req = QueryEventsRequest {
2456            limit: None,
2457            ..QueryEventsRequest {
2458                entity_id: req.entity_id,
2459                event_type: req.event_type,
2460                tenant_id: req.tenant_id,
2461                as_of: req.as_of,
2462                since: req.since,
2463                until: req.until,
2464                limit: None,
2465                event_type_prefix: req.event_type_prefix,
2466                payload_filter: req.payload_filter,
2467            }
2468        };
2469        let all_events = store.query(&unlimited_req).unwrap();
2470        let total_count = all_events.len();
2471        let limited_events: Vec<Event> = if let Some(limit) = requested_limit {
2472            all_events.into_iter().take(limit).collect()
2473        } else {
2474            all_events
2475        };
2476        let count = limited_events.len();
2477        let has_more = count < total_count;
2478
2479        assert_eq!(count, 10);
2480        assert_eq!(total_count, 50);
2481        assert!(has_more);
2482    }
2483
2484    #[tokio::test]
2485    async fn test_query_events_no_more_results() {
2486        let store = create_test_store();
2487
2488        // Ingest 5 events
2489        for i in 0..5 {
2490            store
2491                .ingest(&create_test_event(&format!("entity-{i}"), "user.created"))
2492                .unwrap();
2493        }
2494
2495        // Query with limit=100 — should get has_more=false, total_count=5
2496        let all_events = store
2497            .query(&QueryEventsRequest {
2498                entity_id: None,
2499                event_type: None,
2500                tenant_id: None,
2501                as_of: None,
2502                since: None,
2503                until: None,
2504                limit: None,
2505                event_type_prefix: None,
2506                payload_filter: None,
2507            })
2508            .unwrap();
2509        let total_count = all_events.len();
2510        let limited_events: Vec<Event> = all_events.into_iter().take(100).collect();
2511        let count = limited_events.len();
2512        let has_more = count < total_count;
2513
2514        assert_eq!(count, 5);
2515        assert_eq!(total_count, 5);
2516        assert!(!has_more);
2517    }
2518
2519    // Regression test for issue #177: `order=desc` + `limit=1` must return
2520    // the NEWEST event for an entity, not the oldest. Mirrors the ordering
2521    // logic in `query_events` (store.query → reverse-if-desc → take(limit)).
2522    #[tokio::test]
2523    async fn test_query_events_order_desc_returns_latest() {
2524        let store = create_test_store();
2525
2526        // Three events for the same entity with strictly increasing
2527        // timestamps — mimics a backfill appending a corrected event.
2528        let base = chrono::Utc::now();
2529        for i in 0..3i64 {
2530            let mut event = create_test_event("org-1", "auth.org.updated");
2531            event.timestamp = base + chrono::Duration::seconds(i);
2532            event.version = i + 1;
2533            store.ingest(&event).unwrap();
2534        }
2535
2536        let req = QueryEventsRequest {
2537            entity_id: Some("org-1".to_string()),
2538            ..Default::default()
2539        };
2540        let ascending = store.query(&req).unwrap();
2541        assert_eq!(ascending.len(), 3);
2542        // Ascending order: oldest timestamp first.
2543        assert!(ascending[0].timestamp < ascending[1].timestamp);
2544        assert!(ascending[1].timestamp < ascending[2].timestamp);
2545        let oldest_ts = ascending[0].timestamp;
2546        let newest_ts = ascending[2].timestamp;
2547
2548        // `order=desc`: handler reverses, then `limit=1` takes the front.
2549        let mut descending = ascending.clone();
2550        descending.reverse();
2551        let latest: Vec<Event> = descending.into_iter().take(1).collect();
2552        assert_eq!(latest.len(), 1);
2553        assert_eq!(
2554            latest[0].timestamp, newest_ts,
2555            "order=desc&limit=1 must yield the newest event"
2556        );
2557
2558        // Default (ascending) + limit=1 still yields the oldest event.
2559        let oldest: Vec<Event> = ascending.into_iter().take(1).collect();
2560        assert_eq!(oldest[0].timestamp, oldest_ts);
2561    }
2562
2563    #[tokio::test]
2564    async fn test_list_entities_by_type_prefix() {
2565        let store = create_test_store();
2566
2567        // 3 index entities
2568        store
2569            .ingest(&create_test_event("idx-1", "index.created"))
2570            .unwrap();
2571        store
2572            .ingest(&create_test_event("idx-1", "index.updated"))
2573            .unwrap();
2574        store
2575            .ingest(&create_test_event("idx-2", "index.created"))
2576            .unwrap();
2577        store
2578            .ingest(&create_test_event("idx-3", "index.created"))
2579            .unwrap();
2580        // 2 trade entities
2581        store
2582            .ingest(&create_test_event("trade-1", "trade.created"))
2583            .unwrap();
2584        store
2585            .ingest(&create_test_event("trade-2", "trade.created"))
2586            .unwrap();
2587
2588        // List entities for index.*
2589        let req = ListEntitiesRequest {
2590            event_type_prefix: Some("index.".to_string()),
2591            ..Default::default()
2592        };
2593        let query_req = QueryEventsRequest {
2594            entity_id: None,
2595            event_type: None,
2596            tenant_id: None,
2597            as_of: None,
2598            since: None,
2599            until: None,
2600            limit: None,
2601            event_type_prefix: req.event_type_prefix,
2602            payload_filter: req.payload_filter,
2603        };
2604        let events = store.query(&query_req).unwrap();
2605
2606        // Group and verify
2607        let mut entity_map: std::collections::HashMap<String, Vec<&Event>> =
2608            std::collections::HashMap::new();
2609        for event in &events {
2610            entity_map
2611                .entry(event.entity_id().to_string())
2612                .or_default()
2613                .push(event);
2614        }
2615
2616        assert_eq!(entity_map.len(), 3); // idx-1, idx-2, idx-3
2617        assert_eq!(entity_map["idx-1"].len(), 2); // 2 events for idx-1
2618        assert_eq!(entity_map["idx-2"].len(), 1);
2619        assert_eq!(entity_map["idx-3"].len(), 1);
2620    }
2621
2622    // Issue #178: `list_entities` accepts an `order` param and pages
2623    // deterministically over the resulting sort.
2624    #[tokio::test]
2625    async fn test_list_entities_order_and_pagination() {
2626        let store = create_test_store();
2627
2628        // Three entities with strictly increasing last-event times.
2629        let base = chrono::Utc::now();
2630        for (i, eid) in ["org-a", "org-b", "org-c"].iter().enumerate() {
2631            let mut event = create_test_event(eid, "auth.org.created");
2632            event.timestamp = base + chrono::Duration::seconds(i as i64);
2633            store.ingest(&event).unwrap();
2634        }
2635        let prefix = || Some("auth.org.".to_string());
2636
2637        // Default: newest activity first (desc) — preserves prior behavior.
2638        let desc = list_entities(
2639            State(store.clone()),
2640            Query(ListEntitiesRequest {
2641                event_type_prefix: prefix(),
2642                ..Default::default()
2643            }),
2644        )
2645        .await
2646        .unwrap();
2647        let desc_ids: Vec<&str> = desc
2648            .0
2649            .entities
2650            .iter()
2651            .map(|e| e.entity_id.as_str())
2652            .collect();
2653        assert_eq!(desc_ids, ["org-c", "org-b", "org-a"]);
2654
2655        // order=asc: oldest activity first.
2656        let asc = list_entities(
2657            State(store.clone()),
2658            Query(ListEntitiesRequest {
2659                event_type_prefix: prefix(),
2660                order: Some("asc".to_string()),
2661                ..Default::default()
2662            }),
2663        )
2664        .await
2665        .unwrap();
2666        let asc_ids: Vec<&str> = asc
2667            .0
2668            .entities
2669            .iter()
2670            .map(|e| e.entity_id.as_str())
2671            .collect();
2672        assert_eq!(asc_ids, ["org-a", "org-b", "org-c"]);
2673
2674        // Offset pagination over the deterministic asc order: page 2, size 1.
2675        let page2 = list_entities(
2676            State(store.clone()),
2677            Query(ListEntitiesRequest {
2678                event_type_prefix: prefix(),
2679                order: Some("asc".to_string()),
2680                limit: Some(1),
2681                offset: Some(1),
2682                ..Default::default()
2683            }),
2684        )
2685        .await
2686        .unwrap();
2687        assert_eq!(page2.0.entities.len(), 1);
2688        assert_eq!(page2.0.entities[0].entity_id, "org-b");
2689        assert_eq!(page2.0.total, 3);
2690        assert!(page2.0.has_more);
2691
2692        // Invalid order value is rejected.
2693        let err = list_entities(
2694            State(store.clone()),
2695            Query(ListEntitiesRequest {
2696                event_type_prefix: prefix(),
2697                order: Some("sideways".to_string()),
2698                ..Default::default()
2699            }),
2700        )
2701        .await;
2702        assert!(err.is_err(), "invalid order value must be rejected");
2703    }
2704
2705    fn create_test_event_with_payload(
2706        entity_id: &str,
2707        event_type: &str,
2708        payload: serde_json::Value,
2709    ) -> Event {
2710        Event::from_strings(
2711            event_type.to_string(),
2712            entity_id.to_string(),
2713            "test-stream".to_string(),
2714            payload,
2715            None,
2716        )
2717        .unwrap()
2718    }
2719
2720    #[tokio::test]
2721    async fn test_detect_duplicates_by_payload_fields() {
2722        let store = create_test_store();
2723
2724        // Create entities with duplicate "name" field values
2725        store
2726            .ingest(&create_test_event_with_payload(
2727                "idx-1",
2728                "index.created",
2729                serde_json::json!({"name": "S&P 500", "user_id": "alice"}),
2730            ))
2731            .unwrap();
2732        store
2733            .ingest(&create_test_event_with_payload(
2734                "idx-2",
2735                "index.created",
2736                serde_json::json!({"name": "S&P 500", "user_id": "bob"}),
2737            ))
2738            .unwrap();
2739        store
2740            .ingest(&create_test_event_with_payload(
2741                "idx-3",
2742                "index.created",
2743                serde_json::json!({"name": "NASDAQ", "user_id": "alice"}),
2744            ))
2745            .unwrap();
2746        store
2747            .ingest(&create_test_event_with_payload(
2748                "idx-4",
2749                "index.created",
2750                serde_json::json!({"name": "NASDAQ", "user_id": "carol"}),
2751            ))
2752            .unwrap();
2753        store
2754            .ingest(&create_test_event_with_payload(
2755                "idx-5",
2756                "index.created",
2757                serde_json::json!({"name": "DAX", "user_id": "dave"}),
2758            ))
2759            .unwrap();
2760
2761        // Group by name — should find 2 groups: "S&P 500" (idx-1, idx-2) and "NASDAQ" (idx-3, idx-4)
2762        let query_req = QueryEventsRequest {
2763            entity_id: None,
2764            event_type: None,
2765            tenant_id: None,
2766            as_of: None,
2767            since: None,
2768            until: None,
2769            limit: None,
2770            event_type_prefix: Some("index.".to_string()),
2771            payload_filter: None,
2772        };
2773        let events = store.query(&query_req).unwrap();
2774
2775        // Manually replicate the handler logic for testing
2776        let group_by_fields = vec!["name"];
2777        let mut entity_latest: std::collections::HashMap<String, &Event> =
2778            std::collections::HashMap::new();
2779        for event in &events {
2780            let eid = event.entity_id().to_string();
2781            entity_latest
2782                .entry(eid)
2783                .and_modify(|existing| {
2784                    if event.timestamp() > existing.timestamp() {
2785                        *existing = event;
2786                    }
2787                })
2788                .or_insert(event);
2789        }
2790
2791        let mut groups: std::collections::HashMap<String, Vec<String>> =
2792            std::collections::HashMap::new();
2793        for (entity_id, event) in &entity_latest {
2794            let payload = event.payload();
2795            let mut key_parts = serde_json::Map::new();
2796            for field in &group_by_fields {
2797                let value = payload
2798                    .get(*field)
2799                    .cloned()
2800                    .unwrap_or(serde_json::Value::Null);
2801                key_parts.insert((*field).to_string(), value);
2802            }
2803            let key_str = serde_json::to_string(&key_parts).unwrap_or_default();
2804            groups.entry(key_str).or_default().push(entity_id.clone());
2805        }
2806
2807        let duplicate_groups: Vec<_> = groups
2808            .into_iter()
2809            .filter(|(_, ids)| ids.len() > 1)
2810            .collect();
2811
2812        assert_eq!(duplicate_groups.len(), 2); // S&P 500 and NASDAQ groups
2813        for (_, ids) in &duplicate_groups {
2814            assert_eq!(ids.len(), 2);
2815        }
2816    }
2817
2818    #[tokio::test]
2819    async fn test_detect_duplicates_no_duplicates() {
2820        let store = create_test_store();
2821
2822        // All unique names
2823        store
2824            .ingest(&create_test_event_with_payload(
2825                "idx-1",
2826                "index.created",
2827                serde_json::json!({"name": "A"}),
2828            ))
2829            .unwrap();
2830        store
2831            .ingest(&create_test_event_with_payload(
2832                "idx-2",
2833                "index.created",
2834                serde_json::json!({"name": "B"}),
2835            ))
2836            .unwrap();
2837
2838        let query_req = QueryEventsRequest {
2839            entity_id: None,
2840            event_type: None,
2841            tenant_id: None,
2842            as_of: None,
2843            since: None,
2844            until: None,
2845            limit: None,
2846            event_type_prefix: Some("index.".to_string()),
2847            payload_filter: None,
2848        };
2849        let events = store.query(&query_req).unwrap();
2850
2851        let mut entity_latest: std::collections::HashMap<String, &Event> =
2852            std::collections::HashMap::new();
2853        for event in &events {
2854            entity_latest
2855                .entry(event.entity_id().to_string())
2856                .or_insert(event);
2857        }
2858
2859        let mut groups: std::collections::HashMap<String, Vec<String>> =
2860            std::collections::HashMap::new();
2861        for (entity_id, event) in &entity_latest {
2862            let key_str =
2863                serde_json::to_string(&serde_json::json!({"name": event.payload().get("name")}))
2864                    .unwrap();
2865            groups.entry(key_str).or_default().push(entity_id.clone());
2866        }
2867
2868        let duplicate_groups: Vec<_> = groups
2869            .into_iter()
2870            .filter(|(_, ids)| ids.len() > 1)
2871            .collect();
2872
2873        assert_eq!(duplicate_groups.len(), 0); // No duplicates
2874    }
2875
2876    #[tokio::test]
2877    async fn test_detect_duplicates_multi_field_group_by() {
2878        let store = create_test_store();
2879
2880        // Two entities with same name AND user_id = true duplicate
2881        store
2882            .ingest(&create_test_event_with_payload(
2883                "idx-1",
2884                "index.created",
2885                serde_json::json!({"name": "S&P 500", "user_id": "alice"}),
2886            ))
2887            .unwrap();
2888        store
2889            .ingest(&create_test_event_with_payload(
2890                "idx-2",
2891                "index.created",
2892                serde_json::json!({"name": "S&P 500", "user_id": "alice"}),
2893            ))
2894            .unwrap();
2895        // Same name but different user_id = NOT a duplicate in multi-field group
2896        store
2897            .ingest(&create_test_event_with_payload(
2898                "idx-3",
2899                "index.created",
2900                serde_json::json!({"name": "S&P 500", "user_id": "bob"}),
2901            ))
2902            .unwrap();
2903
2904        let query_req = QueryEventsRequest {
2905            entity_id: None,
2906            event_type: None,
2907            tenant_id: None,
2908            as_of: None,
2909            since: None,
2910            until: None,
2911            limit: None,
2912            event_type_prefix: Some("index.".to_string()),
2913            payload_filter: None,
2914        };
2915        let events = store.query(&query_req).unwrap();
2916
2917        let group_by_fields = vec!["name", "user_id"];
2918        let mut entity_latest: std::collections::HashMap<String, &Event> =
2919            std::collections::HashMap::new();
2920        for event in &events {
2921            entity_latest
2922                .entry(event.entity_id().to_string())
2923                .and_modify(|existing| {
2924                    if event.timestamp() > existing.timestamp() {
2925                        *existing = event;
2926                    }
2927                })
2928                .or_insert(event);
2929        }
2930
2931        let mut groups: std::collections::HashMap<String, Vec<String>> =
2932            std::collections::HashMap::new();
2933        for (entity_id, event) in &entity_latest {
2934            let payload = event.payload();
2935            let mut key_parts = serde_json::Map::new();
2936            for field in &group_by_fields {
2937                let value = payload
2938                    .get(*field)
2939                    .cloned()
2940                    .unwrap_or(serde_json::Value::Null);
2941                key_parts.insert((*field).to_string(), value);
2942            }
2943            let key_str = serde_json::to_string(&key_parts).unwrap_or_default();
2944            groups.entry(key_str).or_default().push(entity_id.clone());
2945        }
2946
2947        let duplicate_groups: Vec<_> = groups
2948            .into_iter()
2949            .filter(|(_, ids)| ids.len() > 1)
2950            .collect();
2951
2952        // Only 1 duplicate group: name=S&P 500, user_id=alice (idx-1, idx-2)
2953        assert_eq!(duplicate_groups.len(), 1);
2954        let (_, ref ids) = duplicate_groups[0];
2955        assert_eq!(ids.len(), 2);
2956        let mut sorted_ids = ids.clone();
2957        sorted_ids.sort();
2958        assert_eq!(sorted_ids, vec!["idx-1", "idx-2"]);
2959    }
2960
2961    #[tokio::test]
2962    async fn test_projection_state_cache() {
2963        let store = create_test_store();
2964
2965        // Test cache insertion
2966        let cache = store.projection_state_cache();
2967        cache.insert(
2968            "entity_snapshots:user-123".to_string(),
2969            serde_json::json!({"name": "Test User", "age": 30}),
2970        );
2971
2972        // Test cache retrieval
2973        let state = cache.get("entity_snapshots:user-123");
2974        assert!(state.is_some());
2975        let state = state.unwrap();
2976        assert_eq!(state["name"], "Test User");
2977        assert_eq!(state["age"], 30);
2978    }
2979
2980    #[tokio::test]
2981    async fn test_projection_manager_list_projections() {
2982        let store = create_test_store();
2983
2984        // List projections (built-in projections should be available)
2985        let projection_manager = store.projection_manager();
2986        let projections = projection_manager.list_projections();
2987
2988        // Should have entity_snapshots and event_counters
2989        assert!(projections.len() >= 2);
2990
2991        let names: Vec<&str> = projections.iter().map(|(name, _)| name.as_str()).collect();
2992        assert!(names.contains(&"entity_snapshots"));
2993        assert!(names.contains(&"event_counters"));
2994    }
2995
2996    #[tokio::test]
2997    async fn test_projection_state_after_event_ingestion() {
2998        let store = create_test_store();
2999
3000        // Ingest an event
3001        let event = create_test_event("user-456", "user.created");
3002        store.ingest(&event).unwrap();
3003
3004        // Get projection state
3005        let projection_manager = store.projection_manager();
3006        let snapshot_projection = projection_manager
3007            .get_projection("entity_snapshots")
3008            .unwrap();
3009
3010        let state = snapshot_projection.get_state("user-456");
3011        assert!(state.is_some());
3012        let state = state.unwrap();
3013        assert_eq!(state["name"], "Test");
3014        assert_eq!(state["value"], 42);
3015    }
3016
3017    #[tokio::test]
3018    async fn test_projection_state_cache_multiple_entities() {
3019        let store = create_test_store();
3020        let cache = store.projection_state_cache();
3021
3022        // Insert multiple entities
3023        for i in 0..10 {
3024            cache.insert(
3025                format!("entity_snapshots:entity-{i}"),
3026                serde_json::json!({"id": i, "status": "active"}),
3027            );
3028        }
3029
3030        // Verify all insertions
3031        assert_eq!(cache.len(), 10);
3032
3033        // Verify each entity
3034        for i in 0..10 {
3035            let key = format!("entity_snapshots:entity-{i}");
3036            let state = cache.get(&key);
3037            assert!(state.is_some());
3038            assert_eq!(state.unwrap()["id"], i);
3039        }
3040    }
3041
3042    #[tokio::test]
3043    async fn test_projection_state_update() {
3044        let store = create_test_store();
3045        let cache = store.projection_state_cache();
3046
3047        // Initial state
3048        cache.insert(
3049            "entity_snapshots:user-789".to_string(),
3050            serde_json::json!({"balance": 100}),
3051        );
3052
3053        // Update state
3054        cache.insert(
3055            "entity_snapshots:user-789".to_string(),
3056            serde_json::json!({"balance": 150}),
3057        );
3058
3059        // Verify update
3060        let state = cache.get("entity_snapshots:user-789").unwrap();
3061        assert_eq!(state["balance"], 150);
3062    }
3063
3064    #[tokio::test]
3065    async fn test_event_counter_projection() {
3066        let store = create_test_store();
3067
3068        // Ingest events of different types
3069        store
3070            .ingest(&create_test_event("user-1", "user.created"))
3071            .unwrap();
3072        store
3073            .ingest(&create_test_event("user-2", "user.created"))
3074            .unwrap();
3075        store
3076            .ingest(&create_test_event("user-1", "user.updated"))
3077            .unwrap();
3078
3079        // Get event counter projection
3080        let projection_manager = store.projection_manager();
3081        let counter_projection = projection_manager.get_projection("event_counters").unwrap();
3082
3083        // Check counts
3084        let created_state = counter_projection.get_state("user.created");
3085        assert!(created_state.is_some());
3086        assert_eq!(created_state.unwrap()["count"], 2);
3087
3088        let updated_state = counter_projection.get_state("user.updated");
3089        assert!(updated_state.is_some());
3090        assert_eq!(updated_state.unwrap()["count"], 1);
3091    }
3092
3093    #[tokio::test]
3094    async fn test_projection_state_cache_key_format() {
3095        let store = create_test_store();
3096        let cache = store.projection_state_cache();
3097
3098        // Test standard key format: {projection_name}:{entity_id}
3099        let key = "orders:order-12345".to_string();
3100        cache.insert(key.clone(), serde_json::json!({"total": 99.99}));
3101
3102        let state = cache.get(&key).unwrap();
3103        assert_eq!(state["total"], 99.99);
3104    }
3105
3106    #[tokio::test]
3107    async fn test_projection_state_cache_removal() {
3108        let store = create_test_store();
3109        let cache = store.projection_state_cache();
3110
3111        // Insert and then remove
3112        cache.insert(
3113            "test:entity-1".to_string(),
3114            serde_json::json!({"data": "value"}),
3115        );
3116        assert_eq!(cache.len(), 1);
3117
3118        cache.remove("test:entity-1");
3119        assert_eq!(cache.len(), 0);
3120        assert!(cache.get("test:entity-1").is_none());
3121    }
3122
3123    #[tokio::test]
3124    async fn test_get_nonexistent_projection() {
3125        let store = create_test_store();
3126        let projection_manager = store.projection_manager();
3127
3128        // Requesting a non-existent projection should return None
3129        let projection = projection_manager.get_projection("nonexistent_projection");
3130        assert!(projection.is_none());
3131    }
3132
3133    #[tokio::test]
3134    async fn test_get_nonexistent_entity_state() {
3135        let store = create_test_store();
3136        let projection_manager = store.projection_manager();
3137
3138        // Get state for non-existent entity
3139        let snapshot_projection = projection_manager
3140            .get_projection("entity_snapshots")
3141            .unwrap();
3142        let state = snapshot_projection.get_state("nonexistent-entity-xyz");
3143        assert!(state.is_none());
3144    }
3145
3146    #[tokio::test]
3147    async fn test_projection_state_cache_concurrent_access() {
3148        let store = create_test_store();
3149        let cache = store.projection_state_cache();
3150
3151        // Simulate concurrent writes
3152        let handles: Vec<_> = (0..10)
3153            .map(|i| {
3154                let cache_clone = cache.clone();
3155                tokio::spawn(async move {
3156                    cache_clone.insert(
3157                        format!("concurrent:entity-{i}"),
3158                        serde_json::json!({"thread": i}),
3159                    );
3160                })
3161            })
3162            .collect();
3163
3164        for handle in handles {
3165            handle.await.unwrap();
3166        }
3167
3168        // All 10 entries should be present
3169        assert_eq!(cache.len(), 10);
3170    }
3171
3172    #[tokio::test]
3173    async fn test_projection_state_large_payload() {
3174        let store = create_test_store();
3175        let cache = store.projection_state_cache();
3176
3177        // Create a large JSON payload (~10KB)
3178        let large_array: Vec<serde_json::Value> = (0..1000)
3179            .map(|i| serde_json::json!({"item": i, "description": "test item with some padding data to increase size"}))
3180            .collect();
3181
3182        cache.insert(
3183            "large:entity-1".to_string(),
3184            serde_json::json!({"items": large_array}),
3185        );
3186
3187        let state = cache.get("large:entity-1").unwrap();
3188        let items = state["items"].as_array().unwrap();
3189        assert_eq!(items.len(), 1000);
3190    }
3191
3192    #[tokio::test]
3193    async fn test_projection_state_complex_json() {
3194        let store = create_test_store();
3195        let cache = store.projection_state_cache();
3196
3197        // Complex nested JSON structure
3198        let complex_state = serde_json::json!({
3199            "user": {
3200                "id": "user-123",
3201                "profile": {
3202                    "name": "John Doe",
3203                    "email": "john@example.com",
3204                    "settings": {
3205                        "theme": "dark",
3206                        "notifications": true
3207                    }
3208                },
3209                "roles": ["admin", "user"],
3210                "metadata": {
3211                    "created_at": "2025-01-01T00:00:00Z",
3212                    "last_login": null
3213                }
3214            }
3215        });
3216
3217        cache.insert("complex:user-123".to_string(), complex_state);
3218
3219        let state = cache.get("complex:user-123").unwrap();
3220        assert_eq!(state["user"]["profile"]["name"], "John Doe");
3221        assert_eq!(state["user"]["roles"][0], "admin");
3222        assert!(state["user"]["metadata"]["last_login"].is_null());
3223    }
3224
3225    #[tokio::test]
3226    async fn test_projection_state_cache_iteration() {
3227        let store = create_test_store();
3228        let cache = store.projection_state_cache();
3229
3230        // Insert entries
3231        for i in 0..5 {
3232            cache.insert(format!("iter:entity-{i}"), serde_json::json!({"index": i}));
3233        }
3234
3235        // Iterate over all entries
3236        let entries: Vec<_> = cache.iter().map(|entry| entry.key().clone()).collect();
3237        assert_eq!(entries.len(), 5);
3238    }
3239
3240    #[tokio::test]
3241    async fn test_projection_manager_get_entity_snapshots() {
3242        let store = create_test_store();
3243        let projection_manager = store.projection_manager();
3244
3245        // Get entity_snapshots projection specifically
3246        let projection = projection_manager.get_projection("entity_snapshots");
3247        assert!(projection.is_some());
3248        assert_eq!(projection.unwrap().name(), "entity_snapshots");
3249    }
3250
3251    #[tokio::test]
3252    async fn test_projection_manager_get_event_counters() {
3253        let store = create_test_store();
3254        let projection_manager = store.projection_manager();
3255
3256        // Get event_counters projection specifically
3257        let projection = projection_manager.get_projection("event_counters");
3258        assert!(projection.is_some());
3259        assert_eq!(projection.unwrap().name(), "event_counters");
3260    }
3261
3262    #[tokio::test]
3263    async fn test_projection_state_cache_overwrite() {
3264        let store = create_test_store();
3265        let cache = store.projection_state_cache();
3266
3267        // Initial value
3268        cache.insert(
3269            "overwrite:entity-1".to_string(),
3270            serde_json::json!({"version": 1}),
3271        );
3272
3273        // Overwrite with new value
3274        cache.insert(
3275            "overwrite:entity-1".to_string(),
3276            serde_json::json!({"version": 2}),
3277        );
3278
3279        // Overwrite again
3280        cache.insert(
3281            "overwrite:entity-1".to_string(),
3282            serde_json::json!({"version": 3}),
3283        );
3284
3285        let state = cache.get("overwrite:entity-1").unwrap();
3286        assert_eq!(state["version"], 3);
3287
3288        // Should still be only 1 entry
3289        assert_eq!(cache.len(), 1);
3290    }
3291
3292    #[tokio::test]
3293    async fn test_projection_state_multiple_projections() {
3294        let store = create_test_store();
3295        let cache = store.projection_state_cache();
3296
3297        // Store states for different projections
3298        cache.insert(
3299            "entity_snapshots:user-1".to_string(),
3300            serde_json::json!({"name": "Alice"}),
3301        );
3302        cache.insert(
3303            "event_counters:user.created".to_string(),
3304            serde_json::json!({"count": 5}),
3305        );
3306        cache.insert(
3307            "custom_projection:order-1".to_string(),
3308            serde_json::json!({"total": 150.0}),
3309        );
3310
3311        // Verify each projection's state
3312        assert_eq!(
3313            cache.get("entity_snapshots:user-1").unwrap()["name"],
3314            "Alice"
3315        );
3316        assert_eq!(
3317            cache.get("event_counters:user.created").unwrap()["count"],
3318            5
3319        );
3320        assert_eq!(
3321            cache.get("custom_projection:order-1").unwrap()["total"],
3322            150.0
3323        );
3324    }
3325
3326    #[tokio::test]
3327    async fn test_bulk_projection_state_access() {
3328        let store = create_test_store();
3329
3330        // Ingest multiple events for different entities
3331        for i in 0..5 {
3332            let event = create_test_event(&format!("bulk-user-{i}"), "user.created");
3333            store.ingest(&event).unwrap();
3334        }
3335
3336        // Get projection and verify bulk access
3337        let projection_manager = store.projection_manager();
3338        let snapshot_projection = projection_manager
3339            .get_projection("entity_snapshots")
3340            .unwrap();
3341
3342        // Verify we can access all entities
3343        for i in 0..5 {
3344            let state = snapshot_projection.get_state(&format!("bulk-user-{i}"));
3345            assert!(state.is_some(), "Entity bulk-user-{i} should have state");
3346        }
3347    }
3348
3349    #[tokio::test]
3350    async fn test_bulk_save_projection_states() {
3351        let store = create_test_store();
3352        let cache = store.projection_state_cache();
3353
3354        // Simulate bulk save request
3355        let states = vec![
3356            BulkSaveStateItem {
3357                entity_id: "bulk-entity-1".to_string(),
3358                state: serde_json::json!({"name": "Entity 1", "value": 100}),
3359            },
3360            BulkSaveStateItem {
3361                entity_id: "bulk-entity-2".to_string(),
3362                state: serde_json::json!({"name": "Entity 2", "value": 200}),
3363            },
3364            BulkSaveStateItem {
3365                entity_id: "bulk-entity-3".to_string(),
3366                state: serde_json::json!({"name": "Entity 3", "value": 300}),
3367            },
3368        ];
3369
3370        let projection_name = "test_projection";
3371
3372        // Save states to cache (simulating bulk_save_projection_states handler)
3373        for item in &states {
3374            cache.insert(
3375                format!("{projection_name}:{}", item.entity_id),
3376                item.state.clone(),
3377            );
3378        }
3379
3380        // Verify all states were saved
3381        assert_eq!(cache.len(), 3);
3382
3383        let state1 = cache.get("test_projection:bulk-entity-1").unwrap();
3384        assert_eq!(state1["name"], "Entity 1");
3385        assert_eq!(state1["value"], 100);
3386
3387        let state2 = cache.get("test_projection:bulk-entity-2").unwrap();
3388        assert_eq!(state2["name"], "Entity 2");
3389        assert_eq!(state2["value"], 200);
3390
3391        let state3 = cache.get("test_projection:bulk-entity-3").unwrap();
3392        assert_eq!(state3["name"], "Entity 3");
3393        assert_eq!(state3["value"], 300);
3394    }
3395
3396    #[tokio::test]
3397    async fn test_bulk_save_empty_states() {
3398        let store = create_test_store();
3399        let cache = store.projection_state_cache();
3400
3401        // Clear cache
3402        cache.clear();
3403
3404        // Empty states should work fine
3405        let states: Vec<BulkSaveStateItem> = vec![];
3406        assert_eq!(states.len(), 0);
3407
3408        // Cache should remain empty
3409        assert_eq!(cache.len(), 0);
3410    }
3411
3412    #[tokio::test]
3413    async fn test_bulk_save_overwrites_existing() {
3414        let store = create_test_store();
3415        let cache = store.projection_state_cache();
3416
3417        // Insert initial state
3418        cache.insert(
3419            "test:entity-1".to_string(),
3420            serde_json::json!({"version": 1, "data": "initial"}),
3421        );
3422
3423        // Bulk save with updated state
3424        let new_state = serde_json::json!({"version": 2, "data": "updated"});
3425        cache.insert("test:entity-1".to_string(), new_state);
3426
3427        // Verify overwrite
3428        let state = cache.get("test:entity-1").unwrap();
3429        assert_eq!(state["version"], 2);
3430        assert_eq!(state["data"], "updated");
3431    }
3432
3433    #[tokio::test]
3434    async fn test_bulk_save_high_volume() {
3435        let store = create_test_store();
3436        let cache = store.projection_state_cache();
3437
3438        // Simulate high volume save (1000 entities)
3439        for i in 0..1000 {
3440            cache.insert(
3441                format!("volume_test:entity-{i}"),
3442                serde_json::json!({"index": i, "status": "active"}),
3443            );
3444        }
3445
3446        // Verify count
3447        assert_eq!(cache.len(), 1000);
3448
3449        // Spot check some entries
3450        assert_eq!(cache.get("volume_test:entity-0").unwrap()["index"], 0);
3451        assert_eq!(cache.get("volume_test:entity-500").unwrap()["index"], 500);
3452        assert_eq!(cache.get("volume_test:entity-999").unwrap()["index"], 999);
3453    }
3454
3455    #[tokio::test]
3456    async fn test_bulk_save_different_projections() {
3457        let store = create_test_store();
3458        let cache = store.projection_state_cache();
3459
3460        // Save to multiple projections in bulk
3461        let projections = ["entity_snapshots", "event_counters", "custom_analytics"];
3462
3463        for proj in &projections {
3464            for i in 0..5 {
3465                cache.insert(
3466                    format!("{proj}:entity-{i}"),
3467                    serde_json::json!({"projection": proj, "id": i}),
3468                );
3469            }
3470        }
3471
3472        // Verify total count (3 projections * 5 entities)
3473        assert_eq!(cache.len(), 15);
3474
3475        // Verify each projection
3476        for proj in &projections {
3477            let state = cache.get(&format!("{proj}:entity-0")).unwrap();
3478            assert_eq!(state["projection"], *proj);
3479        }
3480    }
3481
3482    // -------------------------------------------------------------------------
3483    // Cache-fallback tests for the projection-state read handlers (v0.19.1).
3484    //
3485    // SDK-managed projections write state via save_projection_state /
3486    // bulk_save_projection_states without registering in projection_manager.
3487    // These tests verify the read handlers fall back to the cache instead of
3488    // 404-ing. Registered projection wins where both exist; cache fills the
3489    // gap when not.
3490    // -------------------------------------------------------------------------
3491
3492    #[tokio::test]
3493    async fn get_projection_state_falls_back_to_cache_when_unregistered() {
3494        let store = create_test_store();
3495        store.projection_state_cache().insert(
3496            "assets:BTC".to_string(),
3497            serde_json::json!({"symbol": "BTC", "altname": "Bitcoin"}),
3498        );
3499
3500        let resp = get_projection_state(
3501            State(Arc::clone(&store)),
3502            Path(("assets".to_string(), "BTC".to_string())),
3503        )
3504        .await
3505        .expect("should not error when projection is not registered");
3506
3507        assert_eq!(resp.0["found"], serde_json::Value::Bool(true));
3508        assert_eq!(resp.0["state"]["symbol"], "BTC");
3509        assert_eq!(resp.0["state"]["altname"], "Bitcoin");
3510    }
3511
3512    #[tokio::test]
3513    async fn get_projection_state_returns_not_found_when_absent_everywhere() {
3514        let store = create_test_store();
3515
3516        let resp = get_projection_state(
3517            State(Arc::clone(&store)),
3518            Path(("assets".to_string(), "UNKNOWN".to_string())),
3519        )
3520        .await
3521        .unwrap();
3522
3523        assert_eq!(resp.0["found"], serde_json::Value::Bool(false));
3524        assert_eq!(resp.0["state"], serde_json::Value::Null);
3525    }
3526
3527    #[tokio::test]
3528    async fn get_projection_state_registered_wins_over_cache() {
3529        let store = create_test_store();
3530
3531        // Ingest an event so entity_snapshots (a registered projection) has state.
3532        let event = create_test_event("user-777", "user.created");
3533        store.ingest(&event).unwrap();
3534
3535        // Plant a conflicting cache entry for the same (projection, entity).
3536        store.projection_state_cache().insert(
3537            "entity_snapshots:user-777".to_string(),
3538            serde_json::json!({"stolen": "value"}),
3539        );
3540
3541        let resp = get_projection_state(
3542            State(Arc::clone(&store)),
3543            Path(("entity_snapshots".to_string(), "user-777".to_string())),
3544        )
3545        .await
3546        .unwrap();
3547
3548        // Registered projection wins — cache fallback is only consulted when
3549        // the registered projection has no state for this entity.
3550        assert_eq!(resp.0["found"], serde_json::Value::Bool(true));
3551        assert!(
3552            resp.0["state"].get("stolen").is_none(),
3553            "cache entry must not shadow registered projection state: got {:?}",
3554            resp.0["state"]
3555        );
3556    }
3557
3558    #[tokio::test]
3559    async fn get_projection_state_summary_returns_cache_without_registration() {
3560        let store = create_test_store();
3561        let cache = store.projection_state_cache();
3562        cache.insert("assets:BTC".into(), serde_json::json!({"symbol": "BTC"}));
3563        cache.insert("assets:ETH".into(), serde_json::json!({"symbol": "ETH"}));
3564        // Different projection name — must not appear in the summary.
3565        cache.insert("trades:t-1".into(), serde_json::json!({"x": 1}));
3566
3567        let resp =
3568            get_projection_state_summary(State(Arc::clone(&store)), Path("assets".to_string()))
3569                .await
3570                .unwrap();
3571
3572        assert_eq!(resp.0["total"], 2);
3573        let states = resp.0["states"].as_array().unwrap();
3574        let entity_ids: Vec<&str> = states
3575            .iter()
3576            .map(|s| s["entity_id"].as_str().unwrap())
3577            .collect();
3578        assert!(entity_ids.contains(&"BTC"));
3579        assert!(entity_ids.contains(&"ETH"));
3580    }
3581
3582    #[tokio::test]
3583    async fn bulk_get_projection_states_falls_back_to_cache() {
3584        let store = create_test_store();
3585        let cache = store.projection_state_cache();
3586        cache.insert("assets:BTC".into(), serde_json::json!({"symbol": "BTC"}));
3587        cache.insert("assets:ETH".into(), serde_json::json!({"symbol": "ETH"}));
3588
3589        let req = BulkGetStateRequest {
3590            entity_ids: vec!["BTC".into(), "ETH".into(), "MISSING".into()],
3591        };
3592
3593        let resp = bulk_get_projection_states(
3594            State(Arc::clone(&store)),
3595            Path("assets".to_string()),
3596            Json(req),
3597        )
3598        .await
3599        .unwrap();
3600
3601        assert_eq!(resp.0["total"], 3);
3602        let states = resp.0["states"].as_array().unwrap();
3603        let by_id: std::collections::HashMap<&str, &serde_json::Value> = states
3604            .iter()
3605            .map(|s| (s["entity_id"].as_str().unwrap(), s))
3606            .collect();
3607
3608        assert_eq!(by_id["BTC"]["found"], serde_json::Value::Bool(true));
3609        assert_eq!(by_id["BTC"]["state"]["symbol"], "BTC");
3610        assert_eq!(by_id["ETH"]["found"], serde_json::Value::Bool(true));
3611        assert_eq!(by_id["MISSING"]["found"], serde_json::Value::Bool(false));
3612    }
3613}