lunaris_storage_moon/lib.rs
1//! `MoonStorage` — `StoragePort` impl backed by Moon (Redis-compatible RESP).
2//!
3//! RFC 0001 Wave 1C: every `StoragePort` method now routes through per-scope
4//! keyspace helpers (`keyspace::{scope_prefix, ft_index_name, graph_key, mq_topic}`).
5//! Per-scope FT indices, graph keys, and MQ topics are created lazily on first
6//! write via `ensure_scope`.
7//!
8//! Per blueprint §6, every method is a thin pass-through to a Moon native command:
9//!
10//! | trait method | Moon command(s) |
11//! |-------------------|-------------------------------------------------------------------------|
12//! | `atomic_write` | `TXN.BEGIN` + per-op (`HSET` / `FT.UPSERT` / `GRAPH.QUERY MERGE`) + `TXN.COMMIT` |
13//! | `vector_search` | `FT.SEARCH` (with `TEMPORAL.SNAPSHOT_AT` when `as_of` is `Some`) |
14//! | `graph_traverse` | `GRAPH.QUERY` (with `TEMPORAL.SNAPSHOT_AT` when `as_of` is `Some`) |
15//! | `scan_range` | `SCAN ... MATCH <prefix>*` then `HGET` per matched key |
16//! | `read_as_of` | `TEMPORAL.SNAPSHOT_AT` then `HGET <key> v` |
17//! | `publish` | `MQ.PUSH` |
18//! | `subscribe` | `MQ.POP ... BLOCK` polling stream |
19//! | `capabilities` | constant — Moon-native everything |
20//!
21//! ## Lazy per-scope init
22//!
23//! On first write under a scope, `ensure_scope` creates:
24//! - `FT.CREATE lunaris_{scope}_{kind}_idx` for each of chunks / entities / facts / communities
25//! - `GRAPH.CREATE lunaris_{scope}_graph`
26//!
27//! Subsequent calls for an already-initialized scope skip the Moon round-trips via
28//! an in-memory `initialized_scopes` set (lock-free read path once initialized).
29//!
30//! ## Threat model snapshot (T-01-03-*)
31//!
32//! * `WriteOp::GraphNode { label, ... }` and `WriteOp::GraphEdge { rel, ... }` are
33//! interpolated into Cypher. Callers MUST validate `label` / `rel` against
34//! `^[A-Za-z_][A-Za-z0-9_]*$` — see `crates/lunaris-storage-moon/src/atomic.rs` rustdoc.
35//! Phase 4 (`OPS-04` audit) will move the guard into the trait.
36//! * Connection is cleartext RESP over TCP — Moon is treated as trusted infra inside the
37//! same network boundary as the Lunaris process. TLS lands in Phase 5.
38
39#![deny(rust_2018_idioms, unreachable_pub)]
40#![forbid(unsafe_code)]
41
42// 0.6.2 task 9 — AS_OF honesty guard for the KV readers. Public so callers
43// and tests can read the policy (`is_historical`) without a live Moon.
44pub mod as_of;
45pub mod atomic;
46pub mod client;
47pub mod graph;
48// W2-L2 — FT.INVALIDATE_RANGE raw RESP escape hatch (UC-G3 force-push invalidation).
49// pub(crate): only called from `MoonStorage::invalidate_range` in this file.
50pub(crate) mod invalidate;
51pub mod keyspace;
52pub mod keyword;
53pub mod kv;
54// hotkeys-observability — HOTKEYS raw RESP path (typed SDK has no wrapper).
55pub(crate) mod hotkeys;
56// ft-navigate-recall — FT.NAVIGATE raw RESP path (typed SDK lacks a DECAY slot).
57pub(crate) mod navigate;
58pub mod queue;
59pub mod repair;
60pub(crate) mod retry;
61pub mod scopes;
62// 0.7.0 task 22 — connect-time single-shard guard (RFC 0008 §6 Option C).
63// Public so operators/tests can read the policy and the probe key set without a
64// live Moon, and so an out-of-crate live harness can exercise the classifiers.
65pub mod shards;
66pub mod vector;
67pub mod version;
68
69pub use client::MoonClient;
70pub use shards::ShardTopology;
71pub use version::{MIN_MOON_VERSION, MoonVersion, MoonVersionCheck, check_moon_version};
72
73use std::collections::HashSet;
74use std::sync::Arc;
75
76use async_trait::async_trait;
77use bytes::Bytes;
78use futures::stream::BoxStream;
79use lunaris_core::storage::port::MaintenanceHint;
80use lunaris_core::{
81 CypherQuery, Filter, GraphDecay, GraphResult, Hlc, KeywordHit, KeywordPort, Lsn, NavigateHit,
82 NavigateSpec, QueueMsg, Row, Scope, ScopePage, StorageCapabilities, StorageError, StoragePort,
83 VectorHit, WriteOp,
84};
85use parking_lot::Mutex;
86
87use crate::keyspace::{ft_index_name, graph_key};
88
89/// `StoragePort` backed by a single Moon RESP connection manager.
90///
91/// `initialized_scopes` tracks which scopes have had their FT indices and graph
92/// key created (lazy init on first write). `Mutex` is held only during the
93/// brief check + insert — never across `.await` points.
94#[derive(Debug, Clone)]
95pub struct MoonStorage {
96 pub(crate) client: MoonClient,
97 queue_native: bool,
98 /// Set of scopes whose FT indices + graph key have been created on Moon.
99 /// `parking_lot::Mutex` (not `std::sync::Mutex`) per CLAUDE.md lock discipline.
100 /// The lock is NEVER held across an `.await` — it is taken, the bool is checked,
101 /// optionally the scope is inserted, and the lock is dropped BEFORE the async
102 /// Moon calls in `ensure_scope`.
103 initialized_scopes: Arc<Mutex<HashSet<String>>>,
104}
105
106impl MoonStorage {
107 /// Open a connection to Moon at `url` (`moon://host:port[?ws=workspace]`),
108 /// creating FT vector indices at the default dimension
109 /// ([`client::DEFAULT_VECTOR_DIM`] = 768, matching EmbeddingGemma-300M).
110 pub async fn connect(url: &str) -> Result<Self, StorageError> {
111 Self::connect_with_dim(url, crate::client::DEFAULT_VECTOR_DIM).await
112 }
113
114 /// Like [`MoonStorage::connect`], but creates the FT vector indices at
115 /// `dim` instead of the default 768. `dim` MUST be `> 0`. Moon's
116 /// `FT.CREATE` has no upper cap, so a 1536-d embedder (OpenAI
117 /// `text-embedding-3`) works against Moon out of the box.
118 ///
119 /// ## Operator footgun — existing index won't auto-resize
120 ///
121 /// Moon's `FT.CREATE` is idempotent and does NOT update an existing
122 /// index's schema. If a Moon instance already holds a 768-d `chunks`
123 /// index from a prior run, reopening with a 1536-d embedder leaves the
124 /// 768-d index in place; the mismatch surfaces only on the first vector
125 /// write. Drop the stale index first (`FT.DROPINDEX <name>`).
126 pub async fn connect_with_dim(url: &str, dim: usize) -> Result<Self, StorageError> {
127 let client = MoonClient::connect_with_dim(url, dim).await?;
128 let queue_native = crate::queue::supports_native_queue(&client).await?;
129 Ok(Self { client, queue_native, initialized_scopes: Arc::new(Mutex::new(HashSet::new())) })
130 }
131
132 /// Borrow the underlying client (used by integration tests).
133 pub fn client(&self) -> &MoonClient {
134 &self.client
135 }
136
137 /// Lazily ensure per-scope FT indices and graph key exist on Moon.
138 ///
139 /// On first call for a given scope, creates:
140 /// - `FT.CREATE lunaris_{scope}_{kind}_idx` for chunks / entities / facts / communities
141 /// - `GRAPH.CREATE lunaris_{scope}_graph`
142 ///
143 /// Idempotent: "already exists" errors from Moon are swallowed. Subsequent calls
144 /// for the same scope return immediately (in-memory set check, no Moon I/O).
145 ///
146 /// ## Lock discipline
147 ///
148 /// The `Mutex` is locked only to read/write the `HashSet<String>` — it is
149 /// dropped BEFORE any `.await` call so it is NEVER held across an await point.
150 async fn ensure_scope(&self, scope: &Scope) -> Result<(), StorageError> {
151 let scope_str = scope.as_str().to_string();
152
153 // Fast path: scope already initialized — lock, check, drop.
154 {
155 let guard = self.initialized_scopes.lock();
156 if guard.contains(&scope_str) {
157 return Ok(());
158 }
159 } // lock dropped here
160
161 // Slow path: create FT indices and graph on Moon.
162 self.create_scope_indexes(scope).await?;
163
164 // Mark initialized — lock, insert, drop.
165 {
166 let mut guard = self.initialized_scopes.lock();
167 guard.insert(scope_str);
168 } // lock dropped here
169
170 Ok(())
171 }
172
173 /// Create per-scope FT indices and graph key. Called at most once per scope
174 /// (guarded by `ensure_scope`'s in-memory set).
175 async fn create_scope_indexes(&self, scope: &Scope) -> Result<(), StorageError> {
176 // Single-sourced: the FT vector dimension is configured once on the
177 // underlying client (`connect`/`connect_with_dim`); per-scope indices
178 // inherit it — and the `?quant=` choice — so engine-level
179 // `Lunaris::open` sizing flows through here. Schema construction is
180 // shared with the legacy global `ensure_indexes` via
181 // `client::create_lunaris_index_named` so the two sites can never
182 // diverge.
183 let dim = self.client.dim;
184 let typed = self.client.typed();
185
186 for kind in &["chunks", "entities", "facts", "communities"] {
187 let idx_name = ft_index_name(scope, kind);
188 // The FT prefix must match the key shape written by `atomic.rs::VectorUpsert`:
189 // `{ft_index_name(scope, kind)}:{id_hex}`.
190 let prefix = format!("{idx_name}:");
191 // moon-v051-perf-exploit W1: thread the connect-time `?ef=` choice
192 // through per-scope creation too (quantization already flowed; ef
193 // is the same sticky FT.CREATE-time knob — see client.rs
194 // `parse_ef_runtime` for why there is no hardcoded default).
195 crate::client::create_lunaris_index_named_ef(
196 &typed,
197 &idx_name,
198 kind,
199 &prefix,
200 dim,
201 self.client.quantization,
202 self.client.ef_runtime,
203 )
204 .await?;
205 }
206
207 // Create per-scope graph. Moon does not auto-create graphs on first GRAPH.QUERY.
208 let gkey = graph_key(scope);
209 let typed = self.client.typed();
210 match typed.graph().create(&gkey).await {
211 Ok(_) => {}
212 Err(e) => {
213 let msg = e.to_string();
214 if !(msg.contains("already exists") || msg.contains("Graph already exists")) {
215 return Err(crate::client::moon_err(e));
216 }
217 }
218 }
219
220 Ok(())
221 }
222}
223
224#[async_trait]
225impl StoragePort for MoonStorage {
226 /// RFC 0001 Wave 1C: lazy per-scope init before writing, then route all ops
227 /// through scope-prefixed keys / indices.
228 async fn atomic_write(&self, scope: &Scope, ops: &[WriteOp]) -> Result<Lsn, StorageError> {
229 self.ensure_scope(scope).await?;
230 crate::atomic::atomic_write(&self.client, scope, ops).await
231 }
232
233 /// observability-rollout-maturity — override the additive default with a
234 /// real Moon `PING` so a dead/stalled backend surfaces as `Err` on the
235 /// `/healthz` rollout-cutback probe. Bounded by `LUNARIS_MOON_OP_TIMEOUT`.
236 async fn health_check(&self) -> Result<(), StorageError> {
237 self.client.ping().await
238 }
239
240 /// moon-v051-perf-exploit W1: Moon override of the default no-op. On
241 /// `BulkIngestComplete` at/above `LUNARIS_MOON_COMPACT_MIN` vector
242 /// upserts, force-compacts the scope's vector indexes so recall hits the
243 /// compacted HNSW + exact-rerank segments instead of brute-force mutable
244 /// scans — see `vector::maybe_compact_after_bulk_ingest` for the full
245 /// contract (missing-index tolerance, threshold parsing).
246 async fn maintenance_hint(
247 &self,
248 scope: &Scope,
249 hint: MaintenanceHint,
250 ) -> Result<(), StorageError> {
251 match hint {
252 MaintenanceHint::BulkIngestComplete { vector_upserts } => {
253 crate::vector::maybe_compact_after_bulk_ingest(&self.client, scope, vector_upserts)
254 .await
255 }
256 // `MaintenanceHint` is #[non_exhaustive]: future hints are
257 // advisory by contract, so an unknown one is a no-op, not an error.
258 _ => Ok(()),
259 }
260 }
261
262 #[allow(clippy::too_many_arguments)]
263 async fn vector_search(
264 &self,
265 scope: &Scope,
266 index: &str,
267 query: &[f32],
268 k: usize,
269 filter: Option<&Filter>,
270 as_of: Option<Hlc>,
271 rerank: bool,
272 ) -> Result<Vec<VectorHit>, StorageError> {
273 // Belt-and-suspenders: retry ONCE on a transient connection fault so a
274 // backend flip never bubbles `broken pipe` up through recall. The SDK's
275 // ConnectionManager reconnects underneath; this catches the one command
276 // that races the reconnect. See `crate::retry`.
277 crate::retry::with_conn_retry(|| {
278 crate::vector::vector_search(
279 &self.client,
280 scope,
281 index,
282 query,
283 k,
284 filter,
285 as_of,
286 rerank,
287 )
288 })
289 .await
290 }
291
292 async fn graph_traverse(
293 &self,
294 scope: &Scope,
295 query: &CypherQuery,
296 as_of: Option<Hlc>,
297 ) -> Result<GraphResult, StorageError> {
298 crate::graph::graph_traverse(&self.client, scope, query, as_of).await
299 }
300
301 async fn graph_traverse_decayed(
302 &self,
303 scope: &Scope,
304 query: &CypherQuery,
305 as_of: Option<Hlc>,
306 decay: Option<&GraphDecay>,
307 ) -> Result<GraphResult, StorageError> {
308 match decay {
309 None => crate::graph::graph_traverse(&self.client, scope, query, as_of).await,
310 Some(d) => {
311 crate::graph::graph_traverse_decayed(&self.client, scope, query, as_of, d).await
312 }
313 }
314 }
315
316 async fn vector_navigate(
317 &self,
318 scope: &Scope,
319 index: &str,
320 query: &[f32],
321 k: usize,
322 spec: &NavigateSpec,
323 ) -> Result<Vec<NavigateHit>, StorageError> {
324 crate::navigate::vector_navigate(&self.client, scope, index, query, k, spec).await
325 }
326
327 async fn hot_keys(&self, count: usize) -> Result<Vec<lunaris_core::HotKey>, StorageError> {
328 crate::hotkeys::hot_keys(&self.client, count).await
329 }
330
331 async fn scan_range(
332 &self,
333 scope: &Scope,
334 prefix: &[u8],
335 as_of: Option<Hlc>,
336 ) -> Result<BoxStream<'_, Result<(Bytes, Bytes), StorageError>>, StorageError> {
337 crate::kv::scan_range(&self.client, scope, prefix, as_of).await
338 }
339
340 async fn read_as_of(
341 &self,
342 scope: &Scope,
343 key: &[u8],
344 as_of: Hlc,
345 ) -> Result<Option<Row<Bytes>>, StorageError> {
346 crate::kv::read_as_of(&self.client, scope, key, as_of).await
347 }
348
349 /// Moon has no KV version chain — see [`crate::as_of`] for the full
350 /// rationale and the upstream `TemporalKvIndex` path. Declaring this
351 /// `false` is one half of the contract; `kv::read_as_of` refusing a
352 /// historical pin with `StorageError::NotSupported` is the other.
353 fn supports_historical_kv_reads(&self) -> bool {
354 crate::as_of::HISTORICAL_KV_READS
355 }
356
357 /// HOOK-05 idempotency sidecar (ADD task moon-parity-honesty): gives Moon
358 /// a real dedupe-key lookup. Before this, the trait-default `Ok(None)`
359 /// fall-through minted duplicate episodes for every replayed dedupe key
360 /// (proved live 2026-07-14) — idempotency was documented as available on
361 /// the embedded backend only, a boundary this closes.
362 async fn lookup_by_dedupe_key(
363 &self,
364 scope: &Scope,
365 dedupe_key: &str,
366 ) -> Result<Option<Lsn>, StorageError> {
367 crate::kv::lookup_dedupe(&self.client, scope, dedupe_key).await
368 }
369
370 async fn insert_dedupe_key(
371 &self,
372 scope: &Scope,
373 dedupe_key: &str,
374 lsn: Lsn,
375 ) -> Result<(), StorageError> {
376 crate::kv::insert_dedupe(&self.client, scope, dedupe_key, lsn).await
377 }
378
379 async fn publish(
380 &self,
381 scope: &Scope,
382 topic: &str,
383 partition: u16,
384 payload: Bytes,
385 ) -> Result<u64, StorageError> {
386 crate::queue::publish(&self.client, scope, topic, partition, payload).await
387 }
388
389 async fn subscribe(
390 &self,
391 scope: &Scope,
392 group: &str,
393 topic: &str,
394 partition: u16,
395 ) -> Result<BoxStream<'static, Result<QueueMsg, StorageError>>, StorageError> {
396 crate::queue::subscribe(self.client.clone(), scope, group, topic, partition).await
397 }
398
399 /// Plan 04 D-12 — see `crate::queue::queue_length` (private) for the raw
400 /// `MQ.LENGTH` escape hatch rationale.
401 async fn queue_depth(
402 &self,
403 scope: &Scope,
404 topic: &str,
405 partition: u16,
406 ) -> Result<u64, StorageError> {
407 crate::queue::queue_length(&self.client, scope, topic, partition).await
408 }
409
410 /// W4.6 / D6.3 — non-destructive range read; see
411 /// `crate::queue::queue_range` for why this is `XRANGE` and not the MQ
412 /// consumer surface.
413 async fn queue_range(
414 &self,
415 scope: &Scope,
416 topic: &str,
417 partition: u16,
418 from_ms: Option<u64>,
419 to_ms: Option<u64>,
420 limit: usize,
421 ) -> Result<Vec<QueueMsg>, StorageError> {
422 crate::queue::queue_range(&self.client, scope, topic, partition, from_ms, to_ms, limit)
423 .await
424 }
425
426 /// Cross-scope enumeration via `SCAN MATCH lunaris:*` + key parse.
427 /// Q-U2 lock — lazy SCAN-derived. See `crate::scopes` for the cursor
428 /// model and the Moon-SCAN-cursor vs scope-string-cursor tradeoff.
429 async fn list_scopes(
430 &self,
431 prefix: Option<&str>,
432 limit: usize,
433 cursor: Option<&str>,
434 ) -> Result<ScopePage, StorageError> {
435 crate::scopes::list_scopes(&self.client, prefix, limit, cursor).await
436 }
437
438 /// Bulk-invalidate FT index records via `FT.INVALIDATE_RANGE`.
439 ///
440 /// ## Wire shape
441 ///
442 /// ```text
443 /// FT.INVALIDATE_RANGE <index> <node_id_field> <node_id_value>
444 /// <hlc_wall_field> <hlc_wall_lo> <hlc_wall_hi>
445 /// ```
446 ///
447 /// Returns the integer count of deleted records as `u64`.
448 ///
449 /// ## Escape hatch
450 ///
451 /// `moon-client` v0.1.x does not expose a typed wrapper for
452 /// `FT.INVALIDATE_RANGE`. We reach the underlying
453 /// `redis::aio::MultiplexedConnection` via `MoonClient::inner_mut()` on a
454 /// local clone — the same documented pattern used by the HSCAN escape hatch
455 /// in `kv.rs` (the only other permitted raw-RESP site in this crate per
456 /// Phase 1.5 STORE-09 constraints).
457 ///
458 /// ## Error mapping
459 ///
460 /// - Moon `WRONGTYPE` (index does not exist) → `StorageError::Backend`
461 /// containing `"WRONGTYPE"`. The `Lunaris::invalidate_range` fan-out
462 /// treats this as warn-and-skip (degraded mode).
463 /// - Any other Moon error → `StorageError::Backend`.
464 #[allow(clippy::too_many_arguments)]
465 async fn invalidate_range(
466 &self,
467 scope: &Scope,
468 index: &str,
469 node_id_field: &str,
470 node_id_value: &str,
471 hlc_wall_field: &str,
472 hlc_wall_lo_inclusive: i64,
473 hlc_wall_hi_inclusive: i64,
474 ) -> Result<u64, StorageError> {
475 crate::invalidate::invalidate_range(
476 &self.client,
477 scope,
478 index,
479 node_id_field,
480 node_id_value,
481 hlc_wall_field,
482 hlc_wall_lo_inclusive,
483 hlc_wall_hi_inclusive,
484 )
485 .await
486 }
487
488 fn capabilities(&self) -> StorageCapabilities {
489 StorageCapabilities {
490 // Moon supports AS_OF for FT.SEARCH (vector + keyword) and VALID_AT
491 // for GRAPH.QUERY, but plain HGET does NOT accept temporal clauses.
492 // Per moon/docs/guides/temporal.mdx: "Bi-temporal fields are
493 // currently limited to graph entities (nodes/edges). KV temporal
494 // versioning uses a sparse index" (the sparse index is for
495 // transactional MVCC isolation, NOT for AS_OF reads). Lunaris's
496 // KV `read_as_of` therefore returns current state on Moon —
497 // historical KV reads need a Lunaris-layer versioned-key encoding
498 // (Gap 8 — tracked for follow-up phase). Reporting `false` here
499 // is one half of the contract; `kv::read_as_of` refusing a
500 // historical pin with `StorageError::NotSupported` is the other
501 // (see `crate::as_of`). Live-measurement gap fix 2026-04-21;
502 // 0.7.0 removed the second backend this used to defer to.
503 bi_temporal_native: false,
504 graph_native: true,
505 rerank_native: true,
506 queue_native: self.queue_native,
507 // Moon's FT.CREATE has no dimension cap — report the dimension the
508 // adapter actually created its indices at (default 768d matching
509 // EmbeddingGemma-300M; `connect_with_dim` / `Lunaris::open` size it
510 // to the embedder). This stays an accurate description of what the
511 // FT `vec` field will accept.
512 max_vector_dim: self.client.dim as u32,
513 // Gap 9 closure (2026-04-21): `ensure_indexes` now declares
514 // `SchemaField::Text("content")` on chunks/entities/facts/communities
515 // and `WriteOp::VectorUpsert` writes the `content` field via
516 // `extract_content_for_index` (text/fact_text/name/summary per
517 // index — see that function's table). Moon's
518 // SDK `hybrid_search` (3-weight + sparse_field) therefore resolves
519 // `@content` and `fuse_rrf` opts into `RrfFusion::Moon` for one
520 // round-trip server-side fusion. If the schema regresses (e.g. an
521 // older Moon binary that ignores extra_schema), set this back to
522 // `false` to force the always-correct local fusion path.
523 native_rrf: true,
524 // RFC 0001 §3.6 — Moon's soft FT-index limit is ~512 per node
525 // before recall p99 degrades (Moon docs §6.4). Above this,
526 // operators should consider workspace-level pooling (future RFC).
527 max_scopes_recommended: 512,
528 cypher_dialect: lunaris_core::CypherDialect::Legacy,
529 graph_decay_native: true,
530 graph_navigate_native: true,
531 }
532 }
533}
534
535#[async_trait]
536impl KeywordPort for MoonStorage {
537 /// Wave 2.5A: `KeywordPort::keyword_search` now carries `scope: &Scope`
538 /// (RFC 0001 §3.4 amendment). The Moon backend threads scope through to
539 /// `keyword::keyword_search` which routes to the per-scope FT index
540 /// (`ft_index_name(scope, index)`). Previously this impl used `Scope::dev()`
541 /// as a placeholder — that placeholder is now replaced by the caller-supplied scope.
542 async fn keyword_search(
543 &self,
544 scope: &Scope,
545 index: &str,
546 query: &str,
547 k: usize,
548 filter: Option<&Filter>,
549 as_of: Option<Hlc>,
550 ) -> Result<Vec<KeywordHit>, StorageError> {
551 // Same one-shot reconnect guard as `vector_search` — keyword recall is
552 // the other read path that broke on a dropped socket in production.
553 crate::retry::with_conn_retry(|| {
554 crate::keyword::keyword_search(&self.client, scope, index, query, k, filter, as_of)
555 })
556 .await
557 }
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563
564 /// Compile-time assertion that `MoonStorage` is dyn-compatible.
565 #[allow(dead_code)]
566 fn _moonstorage_is_storage_port() {
567 fn assert_storage_port<T: StoragePort + ?Sized>() {}
568 assert_storage_port::<MoonStorage>();
569 assert_storage_port::<dyn StoragePort>();
570 }
571
572 #[test]
573 fn capabilities_match_moon_profile() {
574 // We can't construct a real `MoonStorage` without a connection, but we can match
575 // the `capabilities()` body shape directly.
576 let want = StorageCapabilities {
577 bi_temporal_native: false,
578 graph_native: true,
579 rerank_native: true,
580 queue_native: true,
581 max_vector_dim: 768,
582 native_rrf: true,
583 max_scopes_recommended: 512,
584 cypher_dialect: lunaris_core::CypherDialect::Legacy,
585 graph_decay_native: true,
586 graph_navigate_native: true,
587 };
588 assert!(
589 !want.bi_temporal_native,
590 "Moon does not natively support KV bi-temporal reads (HGET ignores AS_OF); only FT.SEARCH AS_OF + GRAPH.QUERY VALID_AT are temporal — Gap 8 fix 2026-04-21"
591 );
592 assert!(want.graph_native);
593 assert!(want.rerank_native);
594 assert!(want.queue_native);
595 assert_eq!(want.max_vector_dim, 768);
596 assert!(
597 want.native_rrf,
598 "Moon HYBRID FT.SEARCH now resolves @content via the SchemaField::Text added by ensure_indexes; fuse_rrf opts into RrfFusion::Moon — Gap 9 closure 2026-04-21"
599 );
600 assert_eq!(
601 want.max_scopes_recommended, 512,
602 "Moon FT soft limit is ~512 indices per node (RFC 0001 §3.6)"
603 );
604 }
605}