Skip to main content

spicedb_embedded/
spicedb.rs

1//! FFI bindings for the `SpiceDB` C-shared library (CGO).
2//!
3//! This module provides a thin wrapper that starts `SpiceDB` via FFI and
4//! connects tonic-generated gRPC clients over a Unix socket (Unix) or TCP (Windows).
5//! Schema and relationships are written via gRPC (not JSON).
6
7use serde::{Deserialize, Serialize};
8use spicedb_embedded_sys::{dispose, start};
9use spicedb_grpc_tonic::v1::{
10    RelationshipUpdate, WriteRelationshipsRequest, WriteSchemaRequest,
11    relationship_update::Operation,
12};
13
14use crate::SpiceDBError;
15
16/// Response from the C library (JSON parsed)
17#[derive(Debug, Deserialize)]
18struct CResponse {
19    success: bool,
20    error: Option<String>,
21    data: Option<serde_json::Value>,
22}
23
24/// Options for starting an embedded `SpiceDB` instance.
25#[derive(Debug, Default, Clone, Serialize)]
26#[serde(rename_all = "snake_case")]
27pub struct StartOptions {
28    /// Datastore: "memory" (default), "postgres", "cockroachdb", "spanner", "mysql"
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub datastore: Option<String>,
31    /// Connection string for remote datastores. Required for postgres, cockroachdb, spanner, mysql.
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub datastore_uri: Option<String>,
34    /// Path to Spanner service account JSON (Spanner only)
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub spanner_credentials_file: Option<String>,
37    /// Spanner emulator host, e.g. "localhost:9010" (Spanner only)
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub spanner_emulator_host: Option<String>,
40    /// Prefix for all tables (`MySQL` only)
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub mysql_table_prefix: Option<String>,
43    /// Enable datastore Prometheus metrics (default: false; disabled allows multiple instances in same process)
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub metrics_enabled: Option<bool>,
46}
47
48/// Parses the JSON response string from the C library (start/dispose) into the inner data or error.
49fn parse_json_response(response_str: &str) -> Result<serde_json::Value, SpiceDBError> {
50    let response: CResponse = serde_json::from_str(response_str)
51        .map_err(|e| SpiceDBError::Protocol(format!("invalid JSON: {e} (raw: {response_str})")))?;
52
53    if response.success {
54        Ok(response.data.unwrap_or(serde_json::Value::Null))
55    } else {
56        Err(SpiceDBError::SpiceDB(
57            response.error.unwrap_or_else(|| "unknown error".into()),
58        ))
59    }
60}
61
62use spicedb_embedded_sys::memory_transport;
63use spicedb_grpc_tonic::v1::{
64    CheckBulkPermissionsRequest, CheckBulkPermissionsResponse, CheckPermissionRequest,
65    CheckPermissionResponse, DeleteRelationshipsRequest, DeleteRelationshipsResponse,
66    ExpandPermissionTreeRequest, ExpandPermissionTreeResponse, ReadSchemaRequest,
67    ReadSchemaResponse, WriteRelationshipsResponse, WriteSchemaResponse,
68};
69
70/// Embedded `SpiceDB` instance (in-memory transport). All RPCs go through the FFI.
71/// For streaming APIs (`Watch`, `ReadRelationships`, etc.) use [`streaming_address`](EmbeddedSpiceDB::streaming_address)
72/// (the C library starts a streaming proxy and returns it in the start response).
73pub struct EmbeddedSpiceDB {
74    handle: u64,
75    /// Set from the C library start response; use for `Watch`, `ReadRelationships`, `LookupResources`, `LookupSubjects`.
76    streaming_address: String,
77    /// Set from the C library start response: "unix" or "tcp".
78    streaming_transport: String,
79}
80
81unsafe impl Send for EmbeddedSpiceDB {}
82unsafe impl Sync for EmbeddedSpiceDB {}
83
84impl EmbeddedSpiceDB {
85    /// Start an embedded `SpiceDB` instance without bootstrapping schema or relationships.
86    ///
87    /// Use this when you want to manage schema/relationships yourself, or when
88    /// connecting to a pre-existing datastore that already has a schema.
89    ///
90    /// # Errors
91    ///
92    /// Returns an error if the C library fails to start or returns invalid JSON.
93    pub fn start(options: Option<&StartOptions>) -> Result<Self, SpiceDBError> {
94        let opts = options.cloned().unwrap_or_default();
95        let json = serde_json::to_string(&opts)
96            .map_err(|e| SpiceDBError::Protocol(format!("serialize options: {e}")))?;
97        let response_str = start(Some(&json)).map_err(SpiceDBError::Runtime)?;
98        let data = parse_json_response(&response_str)?;
99        let handle = data
100            .get("handle")
101            .and_then(serde_json::Value::as_u64)
102            .ok_or_else(|| SpiceDBError::Protocol("missing handle in start response".into()))?;
103        let streaming_address = data
104            .get("streaming_address")
105            .and_then(serde_json::Value::as_str)
106            .map(String::from)
107            .ok_or_else(|| {
108                SpiceDBError::Protocol("missing streaming_address in start response".into())
109            })?;
110        let streaming_transport = data
111            .get("streaming_transport")
112            .and_then(serde_json::Value::as_str)
113            .map(String::from)
114            .ok_or_else(|| {
115                SpiceDBError::Protocol("missing streaming_transport in start response".into())
116            })?;
117
118        Ok(Self {
119            handle,
120            streaming_address,
121            streaming_transport,
122        })
123    }
124
125    /// Start an embedded `SpiceDB` instance and bootstrap it with schema and relationships.
126    ///
127    /// If `schema` is non-empty, writes it via `SchemaService`. If `relationships` is non-empty, writes them via `WriteRelationships`.
128    ///
129    /// # Errors
130    ///
131    /// Returns an error if the C library fails to start, returns invalid JSON, or schema/relationship write fails.
132    pub fn start_with_schema(
133        schema: &str,
134        relationships: &[spicedb_grpc_tonic::v1::Relationship],
135        options: Option<&StartOptions>,
136    ) -> Result<Self, SpiceDBError> {
137        let db = Self::start(options)?;
138
139        if !schema.is_empty() {
140            memory_transport::write_schema(
141                db.handle,
142                &WriteSchemaRequest {
143                    schema: schema.to_string(),
144                },
145            )
146            .map_err(|e| SpiceDBError::SpiceDB(e.0))?;
147        }
148
149        if !relationships.is_empty() {
150            let updates: Vec<RelationshipUpdate> = relationships
151                .iter()
152                .map(|r| RelationshipUpdate {
153                    operation: Operation::Touch as i32,
154                    relationship: Some(r.clone()),
155                })
156                .collect();
157            memory_transport::write_relationships(
158                db.handle,
159                &WriteRelationshipsRequest {
160                    updates,
161                    optional_preconditions: vec![],
162                    optional_transaction_metadata: None,
163                },
164            )
165            .map_err(|e| SpiceDBError::SpiceDB(e.0))?;
166        }
167
168        Ok(db)
169    }
170
171    /// Deprecated: use [`start_with_schema`](Self::start_with_schema) instead.
172    ///
173    /// # Errors
174    ///
175    /// See [`start_with_schema`](Self::start_with_schema).
176    #[deprecated(since = "0.6.0", note = "renamed to start_with_schema")]
177    pub fn new(
178        schema: &str,
179        relationships: &[spicedb_grpc_tonic::v1::Relationship],
180        options: Option<&StartOptions>,
181    ) -> Result<Self, SpiceDBError> {
182        Self::start_with_schema(schema, relationships, options)
183    }
184
185    /// Permissions service (`CheckPermission`, `WriteRelationships`, `DeleteRelationships`, etc.).
186    #[must_use]
187    pub const fn permissions(&self) -> MemoryPermissionsClient {
188        MemoryPermissionsClient {
189            handle: self.handle,
190        }
191    }
192
193    /// Schema service (`ReadSchema`, `WriteSchema`).
194    #[must_use]
195    pub const fn schema(&self) -> MemorySchemaClient {
196        MemorySchemaClient {
197            handle: self.handle,
198        }
199    }
200
201    /// Raw handle for advanced use (e.g. with [`spicedb_embedded_sys::memory_transport`]).
202    #[must_use]
203    pub const fn handle(&self) -> u64 {
204        self.handle
205    }
206
207    /// Returns the address for streaming APIs (`Watch`, `ReadRelationships`, `LookupResources`, `LookupSubjects`).
208    /// Set from the C library start response when the streaming proxy was started.
209    #[must_use]
210    pub fn streaming_address(&self) -> &str {
211        &self.streaming_address
212    }
213
214    /// Streaming proxy transport: "unix" or "tcp" (from C library start response).
215    #[must_use]
216    pub fn streaming_transport(&self) -> &str {
217        &self.streaming_transport
218    }
219}
220
221impl Drop for EmbeddedSpiceDB {
222    fn drop(&mut self) {
223        let _ = dispose(self.handle);
224    }
225}
226
227/// Permissions service client for memory transport. All methods are synchronous and use the -sys safe layer.
228pub struct MemoryPermissionsClient {
229    handle: u64,
230}
231
232impl MemoryPermissionsClient {
233    /// `CheckPermission`.
234    ///
235    /// # Errors
236    ///
237    /// Returns an error if the FFI call fails or the response cannot be decoded.
238    pub fn check_permission(
239        &self,
240        request: &CheckPermissionRequest,
241    ) -> Result<CheckPermissionResponse, SpiceDBError> {
242        memory_transport::check_permission(self.handle, request)
243            .map_err(|e| SpiceDBError::SpiceDB(e.0))
244    }
245
246    /// `WriteRelationships`.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error if the FFI call fails or the response cannot be decoded.
251    pub fn write_relationships(
252        &self,
253        request: &WriteRelationshipsRequest,
254    ) -> Result<WriteRelationshipsResponse, SpiceDBError> {
255        memory_transport::write_relationships(self.handle, request)
256            .map_err(|e| SpiceDBError::SpiceDB(e.0))
257    }
258
259    /// `DeleteRelationships`.
260    ///
261    /// # Errors
262    ///
263    /// Returns an error if the FFI call fails or the response cannot be decoded.
264    pub fn delete_relationships(
265        &self,
266        request: &DeleteRelationshipsRequest,
267    ) -> Result<DeleteRelationshipsResponse, SpiceDBError> {
268        memory_transport::delete_relationships(self.handle, request)
269            .map_err(|e| SpiceDBError::SpiceDB(e.0))
270    }
271
272    /// `CheckBulkPermissions`.
273    ///
274    /// # Errors
275    ///
276    /// Returns an error if the FFI call fails or the response cannot be decoded.
277    pub fn check_bulk_permissions(
278        &self,
279        request: &CheckBulkPermissionsRequest,
280    ) -> Result<CheckBulkPermissionsResponse, SpiceDBError> {
281        memory_transport::check_bulk_permissions(self.handle, request)
282            .map_err(|e| SpiceDBError::SpiceDB(e.0))
283    }
284
285    /// `ExpandPermissionTree`.
286    ///
287    /// # Errors
288    ///
289    /// Returns an error if the FFI call fails or the response cannot be decoded.
290    pub fn expand_permission_tree(
291        &self,
292        request: &ExpandPermissionTreeRequest,
293    ) -> Result<ExpandPermissionTreeResponse, SpiceDBError> {
294        memory_transport::expand_permission_tree(self.handle, request)
295            .map_err(|e| SpiceDBError::SpiceDB(e.0))
296    }
297}
298
299/// Schema service client for memory transport.
300pub struct MemorySchemaClient {
301    handle: u64,
302}
303
304impl MemorySchemaClient {
305    /// `ReadSchema`.
306    ///
307    /// # Errors
308    ///
309    /// Returns an error if the FFI call fails or the response cannot be decoded.
310    pub fn read_schema(
311        &self,
312        request: &ReadSchemaRequest,
313    ) -> Result<ReadSchemaResponse, SpiceDBError> {
314        memory_transport::read_schema(self.handle, request).map_err(|e| SpiceDBError::SpiceDB(e.0))
315    }
316
317    /// `WriteSchema`.
318    ///
319    /// # Errors
320    ///
321    /// Returns an error if the FFI call fails or the response cannot be decoded.
322    pub fn write_schema(
323        &self,
324        request: &WriteSchemaRequest,
325    ) -> Result<WriteSchemaResponse, SpiceDBError> {
326        memory_transport::write_schema(self.handle, request).map_err(|e| SpiceDBError::SpiceDB(e.0))
327    }
328}
329
330#[cfg(test)]
331mod tests {
332
333    use std::time::Duration;
334
335    use spicedb_grpc_tonic::v1::{
336        CheckPermissionRequest, Consistency, ObjectReference, ReadRelationshipsRequest,
337        Relationship, RelationshipFilter, RelationshipUpdate, SubjectReference, WatchKind,
338        WatchRequest, WriteRelationshipsRequest, relationship_update::Operation,
339        watch_service_client::WatchServiceClient,
340    };
341    use tokio::time::timeout;
342    use tokio_stream::StreamExt;
343    use tonic::transport::{Channel, Endpoint};
344
345    use super::*;
346    use crate::v1::check_permission_response::Permissionship;
347
348    /// Connect to the streaming proxy (addr + transport from C library start response).
349    #[allow(unused_variables)] // transport only used on Unix
350    async fn connect_streaming(
351        addr: &str,
352        transport: &str,
353    ) -> Result<Channel, Box<dyn std::error::Error + Send + Sync>> {
354        #[cfg(unix)]
355        {
356            if transport == "unix" {
357                let path = addr.to_string();
358                Endpoint::try_from("http://[::]:50051")?
359                    .connect_with_connector(tower::service_fn(move |_: tonic::transport::Uri| {
360                        let path = path.clone();
361                        async move {
362                            let stream = tokio::net::UnixStream::connect(&path).await?;
363                            Ok::<_, std::io::Error>(hyper_util::rt::TokioIo::new(stream))
364                        }
365                    }))
366                    .await
367                    .map_err(Into::into)
368            } else {
369                Endpoint::from_shared(format!("http://{addr}"))?
370                    .connect()
371                    .await
372                    .map_err(Into::into)
373            }
374        }
375        #[cfg(windows)]
376        {
377            Endpoint::from_shared(format!("http://{addr}"))?
378                .connect()
379                .await
380                .map_err(Into::into)
381        }
382    }
383
384    const TEST_SCHEMA: &str = r"
385definition user {}
386
387definition document {
388    relation reader: user
389    relation writer: user
390
391    permission read = reader + writer
392    permission write = writer
393}
394";
395
396    fn rel(resource: &str, relation: &str, subject: &str) -> Relationship {
397        let (res_type, res_id) = resource.split_once(':').unwrap();
398        let (sub_type, sub_id) = subject.split_once(':').unwrap();
399        Relationship {
400            resource: Some(ObjectReference {
401                object_type: res_type.into(),
402                object_id: res_id.into(),
403            }),
404            relation: relation.into(),
405            subject: Some(SubjectReference {
406                object: Some(ObjectReference {
407                    object_type: sub_type.into(),
408                    object_id: sub_id.into(),
409                }),
410                optional_relation: String::new(),
411            }),
412            optional_caveat: None,
413            optional_expires_at: None,
414        }
415    }
416
417    fn fully_consistent() -> Consistency {
418        Consistency {
419            requirement: Some(crate::v1::consistency::Requirement::FullyConsistent(true)),
420        }
421    }
422
423    fn check_req(resource: &str, permission: &str, subject: &str) -> CheckPermissionRequest {
424        let (res_type, res_id) = resource.split_once(':').unwrap();
425        let (sub_type, sub_id) = subject.split_once(':').unwrap();
426        CheckPermissionRequest {
427            consistency: Some(fully_consistent()),
428            resource: Some(ObjectReference {
429                object_type: res_type.into(),
430                object_id: res_id.into(),
431            }),
432            permission: permission.into(),
433            subject: Some(SubjectReference {
434                object: Some(ObjectReference {
435                    object_type: sub_type.into(),
436                    object_id: sub_id.into(),
437                }),
438                optional_relation: String::new(),
439            }),
440            context: None,
441            with_tracing: false,
442        }
443    }
444
445    /// `EmbeddedSpiceDB::start_with_schema` + `.permissions().check_permission()`. Skipped on bind/streaming proxy errors.
446    #[test]
447    fn test_check_permission() {
448        let relationships = vec![
449            rel("document:readme", "reader", "user:alice"),
450            rel("document:readme", "writer", "user:bob"),
451        ];
452        let spicedb = match EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &relationships, None) {
453            Ok(db) => db,
454            Err(e) => {
455                let msg = e.to_string();
456                if msg.contains("streaming proxy")
457                    || msg.contains("bind")
458                    || msg.contains("operation not permitted")
459                {
460                    return;
461                }
462                panic!("EmbeddedSpiceDB::start_with_schema failed: {e}");
463            }
464        };
465
466        let response = spicedb
467            .permissions()
468            .check_permission(&check_req("document:readme", "read", "user:alice"))
469            .unwrap();
470        assert_eq!(
471            response.permissionship,
472            Permissionship::HasPermission as i32,
473            "alice should have read on document:readme"
474        );
475    }
476
477    /// Verifies the streaming proxy: start Watch stream, write a relationship, receive update on stream. Skipped on bind/proxy errors.
478    #[test]
479    fn test_watch_streaming() {
480        let db = match EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None) {
481            Ok(d) => d,
482            Err(e) => {
483                let msg = e.to_string();
484                if msg.contains("streaming proxy")
485                    || msg.contains("bind")
486                    || msg.contains("operation not permitted")
487                {
488                    return;
489                }
490                panic!("EmbeddedSpiceDB::start_with_schema failed: {e}");
491            }
492        };
493
494        let rt = tokio::runtime::Runtime::new().unwrap();
495        rt.block_on(async {
496            let channel = connect_streaming(db.streaming_address(), db.streaming_transport())
497                .await
498                .unwrap();
499            let mut watch_client = WatchServiceClient::new(channel);
500            // Request checkpoints so the server sends an initial response and keeps the stream alive.
501            let watch_req = WatchRequest {
502                optional_update_kinds: vec![
503                    WatchKind::IncludeRelationshipUpdates.into(),
504                    WatchKind::IncludeCheckpoints.into(),
505                ],
506                ..Default::default()
507            };
508            let mut stream =
509                match timeout(Duration::from_secs(10), watch_client.watch(watch_req)).await {
510                    Ok(Ok(response)) => response.into_inner(),
511                    Ok(Err(e)) => panic!("watch() failed: {e}"),
512                    Err(_) => return,
513                };
514
515            let write_req = WriteRelationshipsRequest {
516                updates: vec![RelationshipUpdate {
517                    operation: Operation::Touch as i32,
518                    relationship: Some(rel("document:watched", "reader", "user:alice")),
519                }],
520                optional_preconditions: vec![],
521                optional_transaction_metadata: None,
522            };
523            db.permissions().write_relationships(&write_req).unwrap();
524
525            let mut received_update = false;
526            let deadline = tokio::time::Instant::now() + Duration::from_secs(3);
527            while tokio::time::Instant::now() < deadline {
528                match timeout(Duration::from_millis(200), stream.next()).await {
529                    Ok(Some(Ok(resp))) => {
530                        if !resp.updates.is_empty() {
531                            received_update = true;
532                            break;
533                        }
534                    }
535                    Ok(Some(Err(e))) => panic!("watch stream error: {e}"),
536                    Ok(None) => break,
537                    Err(_) => {}
538                }
539            }
540            assert!(
541                received_update,
542                "expected at least one Watch response with updates within 3s"
543            );
544        });
545    }
546
547    #[test]
548    fn test_ffi_spicedb() {
549        let relationships = vec![
550            rel("document:readme", "reader", "user:alice"),
551            rel("document:readme", "writer", "user:bob"),
552        ];
553
554        let spicedb =
555            EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &relationships, None).unwrap();
556
557        assert_eq!(
558            spicedb
559                .permissions()
560                .check_permission(&check_req("document:readme", "read", "user:alice"))
561                .unwrap()
562                .permissionship,
563            Permissionship::HasPermission as i32,
564        );
565        assert_eq!(
566            spicedb
567                .permissions()
568                .check_permission(&check_req("document:readme", "write", "user:alice"))
569                .unwrap()
570                .permissionship,
571            Permissionship::NoPermission as i32,
572        );
573        assert_eq!(
574            spicedb
575                .permissions()
576                .check_permission(&check_req("document:readme", "read", "user:bob"))
577                .unwrap()
578                .permissionship,
579            Permissionship::HasPermission as i32,
580        );
581        assert_eq!(
582            spicedb
583                .permissions()
584                .check_permission(&check_req("document:readme", "write", "user:bob"))
585                .unwrap()
586                .permissionship,
587            Permissionship::HasPermission as i32,
588        );
589        assert_eq!(
590            spicedb
591                .permissions()
592                .check_permission(&check_req("document:readme", "read", "user:charlie"))
593                .unwrap()
594                .permissionship,
595            Permissionship::NoPermission as i32,
596        );
597    }
598
599    #[test]
600    fn test_add_relationship() {
601        let spicedb = EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None).unwrap();
602
603        spicedb
604            .permissions()
605            .write_relationships(&WriteRelationshipsRequest {
606                updates: vec![RelationshipUpdate {
607                    operation: Operation::Touch as i32,
608                    relationship: Some(rel("document:test", "reader", "user:alice")),
609                }],
610                optional_preconditions: vec![],
611                optional_transaction_metadata: None,
612            })
613            .unwrap();
614
615        let r = spicedb
616            .permissions()
617            .check_permission(&check_req("document:test", "read", "user:alice"))
618            .unwrap();
619        assert_eq!(r.permissionship, Permissionship::HasPermission as i32);
620    }
621
622    #[test]
623    fn test_parallel_instances() {
624        let spicedb1 = EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None).unwrap();
625        let spicedb2 = EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None).unwrap();
626
627        spicedb1
628            .permissions()
629            .write_relationships(&WriteRelationshipsRequest {
630                updates: vec![RelationshipUpdate {
631                    operation: Operation::Touch as i32,
632                    relationship: Some(rel("document:doc1", "reader", "user:alice")),
633                }],
634                optional_preconditions: vec![],
635                optional_transaction_metadata: None,
636            })
637            .unwrap();
638
639        let r1 = spicedb1
640            .permissions()
641            .check_permission(&check_req("document:doc1", "read", "user:alice"))
642            .unwrap();
643        assert_eq!(r1.permissionship, Permissionship::HasPermission as i32);
644
645        let r2 = spicedb2
646            .permissions()
647            .check_permission(&check_req("document:doc1", "read", "user:alice"))
648            .unwrap();
649        assert_eq!(r2.permissionship, Permissionship::NoPermission as i32);
650    }
651
652    #[tokio::test]
653    async fn test_read_relationships() {
654        let relationships = vec![
655            rel("document:doc1", "reader", "user:alice"),
656            rel("document:doc1", "reader", "user:bob"),
657        ];
658
659        let spicedb =
660            EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &relationships, None).unwrap();
661        let channel = connect_streaming(spicedb.streaming_address(), spicedb.streaming_transport())
662            .await
663            .unwrap();
664        let mut client =
665            spicedb_grpc_tonic::v1::permissions_service_client::PermissionsServiceClient::new(
666                channel,
667            );
668        let mut stream = client
669            .read_relationships(ReadRelationshipsRequest {
670                consistency: Some(fully_consistent()),
671                relationship_filter: Some(RelationshipFilter {
672                    resource_type: "document".into(),
673                    optional_resource_id: "doc1".into(),
674                    optional_resource_id_prefix: String::new(),
675                    optional_relation: String::new(),
676                    optional_subject_filter: None,
677                }),
678                optional_limit: 0,
679                optional_cursor: None,
680            })
681            .await
682            .unwrap()
683            .into_inner();
684
685        let mut count = 0;
686        while let Some(Ok(_)) = stream.next().await {
687            count += 1;
688        }
689        assert_eq!(count, 2);
690    }
691
692    /// Performance test - run with `cargo test perf_ -- --nocapture --ignored`
693    #[test]
694    #[ignore = "performance test - run manually with --ignored flag"]
695    fn perf_check_with_1000_relationships() {
696        const NUM_CHECKS: usize = 100;
697        use std::time::Instant;
698
699        // Create 1000 relationships
700        let relationships: Vec<Relationship> = (0..1000)
701            .map(|i| {
702                rel(
703                    &format!("document:doc{i}"),
704                    "reader",
705                    &format!("user:user{}", i % 100),
706                )
707            })
708            .collect();
709
710        println!("\n=== Performance: Check with 1000 relationships ===");
711
712        let start = Instant::now();
713        let spicedb =
714            EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &relationships, None).unwrap();
715        println!(
716            "Instance creation with 1000 relationships: {:?}",
717            start.elapsed()
718        );
719
720        // Warm up
721        for _ in 0..10 {
722            let _ = spicedb.permissions().check_permission(&check_req(
723                "document:doc0",
724                "read",
725                "user:user0",
726            ));
727        }
728
729        // Benchmark permission checks
730        let start = Instant::now();
731        for i in 0..NUM_CHECKS {
732            let doc = format!("document:doc{}", i % 1000);
733            let user = format!("user:user{}", i % 100);
734            let _ = spicedb
735                .permissions()
736                .check_permission(&check_req(&doc, "read", &user))
737                .unwrap();
738        }
739        let elapsed = start.elapsed();
740
741        let num_checks_u32 = u32::try_from(NUM_CHECKS).unwrap();
742        println!("Total time for {NUM_CHECKS} checks: {elapsed:?}");
743        println!("Average per check: {:?}", elapsed / num_checks_u32);
744        println!(
745            "Checks per second: {:.0}",
746            f64::from(num_checks_u32) / elapsed.as_secs_f64()
747        );
748    }
749
750    /// Performance test for individual relationship additions
751    #[test]
752    #[ignore = "performance test - run manually with --ignored flag"]
753    fn perf_add_individual_relationships() {
754        const NUM_ADDS: usize = 50;
755        use std::time::Instant;
756
757        let spicedb = EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None).unwrap();
758
759        println!("\n=== Performance: Add individual relationships ===");
760
761        let start = Instant::now();
762        for i in 0..NUM_ADDS {
763            spicedb
764                .permissions()
765                .write_relationships(&WriteRelationshipsRequest {
766                    updates: vec![RelationshipUpdate {
767                        operation: Operation::Touch as i32,
768                        relationship: Some(rel(
769                            &format!("document:perf{i}"),
770                            "reader",
771                            "user:alice",
772                        )),
773                    }],
774                    optional_preconditions: vec![],
775                    optional_transaction_metadata: None,
776                })
777                .unwrap();
778        }
779        let elapsed = start.elapsed();
780
781        let num_adds_u32 = u32::try_from(NUM_ADDS).unwrap();
782        println!("Total time for {NUM_ADDS} individual adds: {elapsed:?}");
783        println!("Average per add: {:?}", elapsed / num_adds_u32);
784        println!(
785            "Adds per second: {:.0}",
786            f64::from(num_adds_u32) / elapsed.as_secs_f64()
787        );
788    }
789
790    /// Performance test for bulk relationship writes
791    #[test]
792    #[ignore = "performance test - run manually with --ignored flag"]
793    fn perf_bulk_write_relationships() {
794        use std::time::Instant;
795
796        let spicedb = EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None).unwrap();
797
798        println!("\n=== Performance: Bulk write relationships ===");
799
800        // Test different batch sizes
801        for batch_size in [5_i32, 10, 20, 50] {
802            let batch_size_u32 = u32::try_from(batch_size).unwrap();
803            let relationships: Vec<Relationship> = (0..batch_size)
804                .map(|i| {
805                    rel(
806                        &format!("document:bulk{batch_size}_{i}"),
807                        "reader",
808                        "user:alice",
809                    )
810                })
811                .collect();
812
813            let start = Instant::now();
814            spicedb
815                .permissions()
816                .write_relationships(&WriteRelationshipsRequest {
817                    updates: relationships
818                        .iter()
819                        .map(|r| RelationshipUpdate {
820                            operation: Operation::Touch as i32,
821                            relationship: Some(r.clone()),
822                        })
823                        .collect(),
824                    optional_preconditions: vec![],
825                    optional_transaction_metadata: None,
826                })
827                .unwrap();
828            let elapsed = start.elapsed();
829
830            println!(
831                "Batch of {} relationships: {:?} ({:?} per relationship)",
832                batch_size,
833                elapsed,
834                elapsed / batch_size_u32
835            );
836        }
837
838        // Compare: 10 individual vs 10 bulk
839        println!("\n--- Comparison: 10 individual vs 10 bulk ---");
840
841        let spicedb2 = EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None).unwrap();
842
843        let start = Instant::now();
844        for i in 0..10 {
845            spicedb2
846                .permissions()
847                .write_relationships(&WriteRelationshipsRequest {
848                    updates: vec![RelationshipUpdate {
849                        operation: Operation::Touch as i32,
850                        relationship: Some(rel(
851                            &format!("document:cmp_ind{i}"),
852                            "reader",
853                            "user:bob",
854                        )),
855                    }],
856                    optional_preconditions: vec![],
857                    optional_transaction_metadata: None,
858                })
859                .unwrap();
860        }
861        let individual_time = start.elapsed();
862        println!("10 individual adds: {individual_time:?}");
863
864        let relationships: Vec<Relationship> = (0..10)
865            .map(|i| rel(&format!("document:cmp_bulk{i}"), "reader", "user:bob"))
866            .collect();
867        let start = Instant::now();
868        spicedb2
869            .permissions()
870            .write_relationships(&WriteRelationshipsRequest {
871                updates: relationships
872                    .iter()
873                    .map(|r| RelationshipUpdate {
874                        operation: Operation::Touch as i32,
875                        relationship: Some(r.clone()),
876                    })
877                    .collect(),
878                optional_preconditions: vec![],
879                optional_transaction_metadata: None,
880            })
881            .unwrap();
882        let bulk_time = start.elapsed();
883        println!("10 bulk add: {bulk_time:?}");
884        println!(
885            "Speedup: {:.1}x",
886            individual_time.as_secs_f64() / bulk_time.as_secs_f64()
887        );
888    }
889
890    /// Performance test: 50,000 relationships
891    #[test]
892    #[ignore = "performance test - run manually with --ignored flag"]
893    fn perf_embedded_50k_relationships() {
894        const TOTAL_RELS: usize = 50_000;
895        const BATCH_SIZE: usize = 1000;
896        const NUM_CHECKS: usize = 500;
897        use std::time::Instant;
898
899        println!("\n=== Embedded SpiceDB: 50,000 relationships ===");
900
901        // Create 50,000 relationships in batches (SpiceDB max batch is 1000)
902        println!("Creating instance...");
903        let start = Instant::now();
904        let spicedb = EmbeddedSpiceDB::start_with_schema(TEST_SCHEMA, &[], None).unwrap();
905        println!("Instance creation time: {:?}", start.elapsed());
906
907        println!("Adding {TOTAL_RELS} relationships in batches of {BATCH_SIZE}...");
908        let start = Instant::now();
909        for batch_num in 0..(TOTAL_RELS / BATCH_SIZE) {
910            let batch_start = batch_num * BATCH_SIZE;
911            let relationships: Vec<Relationship> = (batch_start..batch_start + BATCH_SIZE)
912                .map(|i| {
913                    rel(
914                        &format!("document:doc{i}"),
915                        "reader",
916                        &format!("user:user{}", i % 1000),
917                    )
918                })
919                .collect();
920            spicedb
921                .permissions()
922                .write_relationships(&WriteRelationshipsRequest {
923                    updates: relationships
924                        .iter()
925                        .map(|r| RelationshipUpdate {
926                            operation: Operation::Touch as i32,
927                            relationship: Some(r.clone()),
928                        })
929                        .collect(),
930                    optional_preconditions: vec![],
931                    optional_transaction_metadata: None,
932                })
933                .unwrap();
934        }
935        println!(
936            "Total time to add {} relationships: {:?}",
937            TOTAL_RELS,
938            start.elapsed()
939        );
940
941        // Warm up
942        for _ in 0..20 {
943            let _ = spicedb.permissions().check_permission(&check_req(
944                "document:doc0",
945                "read",
946                "user:user0",
947            ));
948        }
949
950        // Benchmark permission checks
951        let num_checks_u32 = u32::try_from(NUM_CHECKS).unwrap();
952        let start = Instant::now();
953        for i in 0..NUM_CHECKS {
954            let doc = format!("document:doc{}", i % TOTAL_RELS);
955            let user = format!("user:user{}", i % 1000);
956            let _ = spicedb
957                .permissions()
958                .check_permission(&check_req(&doc, "read", &user))
959                .unwrap();
960        }
961        let elapsed = start.elapsed();
962
963        println!("Total time for {NUM_CHECKS} checks: {elapsed:?}");
964        println!("Average per check: {:?}", elapsed / num_checks_u32);
965        println!(
966            "Checks per second: {:.0}",
967            f64::from(num_checks_u32) / elapsed.as_secs_f64()
968        );
969
970        // Test some negative checks too
971        let start = Instant::now();
972        for i in 0..NUM_CHECKS {
973            let doc = format!("document:doc{}", i % TOTAL_RELS);
974            // user:nonexistent doesn't exist
975            let _ = spicedb
976                .permissions()
977                .check_permission(&check_req(&doc, "read", "user:nonexistent"))
978                .unwrap();
979        }
980        let elapsed = start.elapsed();
981        println!("\nNegative checks (user not found):");
982        println!("Average per check: {:?}", elapsed / num_checks_u32);
983    }
984
985    /// Shared-datastore tests: two embedded servers using the same remote datastore.
986    /// Run with: cargo test --ignored `datastore_shared`
987    ///
988    /// Requires: Docker (linux/amd64 images), and `spicedb` CLI in PATH for migrations.
989    /// Only run on `x86_64`: amd64 containers fail on arm64 (exec format error / QEMU).
990    #[cfg(all(not(target_os = "windows"), target_arch = "x86_64"))]
991    mod datastore_shared {
992        /// Platform for testcontainers: use Linux so images work on Windows Docker Desktop.
993        const LINUX_AMD64: &str = "linux/amd64";
994        use std::process::Command;
995
996        use testcontainers_modules::{
997            cockroach_db, mysql, postgres,
998            testcontainers::{
999                GenericImage, ImageExt,
1000                core::{IntoContainerPort, WaitFor},
1001                runners::AsyncRunner,
1002            },
1003        };
1004
1005        use super::*;
1006        use crate::StartOptions;
1007
1008        /// Run `spicedb datastore migrate head` for the given engine and URI.
1009        /// Returns Ok(()) on success, Err on failure. Fails the test if spicedb not found.
1010        fn run_migrate(engine: &str, uri: &str, extra_args: &[(&str, &str)]) -> Result<(), String> {
1011            let mut cmd = Command::new("spicedb");
1012            cmd.args([
1013                "datastore",
1014                "migrate",
1015                "head",
1016                "--datastore-engine",
1017                engine,
1018                "--datastore-conn-uri",
1019                uri,
1020            ]);
1021            for (k, v) in extra_args {
1022                cmd.arg(format!("--{k}={v}"));
1023            }
1024            let output = cmd
1025                .output()
1026                .map_err(|e| format!("spicedb migrate failed (is spicedb in PATH?): {e}"))?;
1027            if output.status.success() {
1028                Ok(())
1029            } else {
1030                let stderr = String::from_utf8_lossy(&output.stderr);
1031                Err(format!("spicedb migrate failed: {stderr}"))
1032            }
1033        }
1034
1035        /// Two servers, shared datastore: write via server 1, read via server 2.
1036        fn run_shared_datastore_test(datastore: &str, datastore_uri: &str) {
1037            run_migrate(datastore, datastore_uri, &[]).expect("migration must succeed");
1038
1039            let opts = StartOptions {
1040                datastore: Some(datastore.into()),
1041                datastore_uri: Some(datastore_uri.into()),
1042                ..Default::default()
1043            };
1044
1045            let schema = TEST_SCHEMA;
1046            let db1 = EmbeddedSpiceDB::start_with_schema(schema, &[], Some(&opts)).unwrap();
1047            let db2 = EmbeddedSpiceDB::start_with_schema(schema, &[], Some(&opts)).unwrap();
1048
1049            // Write via server 1
1050            db1.permissions()
1051                .write_relationships(&WriteRelationshipsRequest {
1052                    updates: vec![RelationshipUpdate {
1053                        operation: Operation::Touch as i32,
1054                        relationship: Some(rel("document:shared", "reader", "user:alice")),
1055                    }],
1056                    optional_preconditions: vec![],
1057                    optional_transaction_metadata: None,
1058                })
1059                .unwrap();
1060
1061            // Read via server 2 (shared datastore)
1062            let r = db2
1063                .permissions()
1064                .check_permission(&check_req("document:shared", "read", "user:alice"))
1065                .unwrap();
1066            assert_eq!(r.permissionship, Permissionship::HasPermission as i32);
1067        }
1068
1069        #[tokio::test]
1070        async fn datastore_shared_postgres() {
1071            // PostgreSQL 17+ required for xid8 type (SpiceDB add-xid-columns migration)
1072            let container = postgres::Postgres::default()
1073                .with_tag("17")
1074                .with_platform(LINUX_AMD64)
1075                .start()
1076                .await
1077                .unwrap();
1078            let host = container.get_host().await.unwrap();
1079            let port = container.get_host_port_ipv4(5432).await.unwrap();
1080            let uri = format!("postgres://postgres:postgres@{host}:{port}/postgres");
1081            run_shared_datastore_test("postgres", &uri);
1082        }
1083
1084        #[tokio::test]
1085        async fn datastore_shared_cockroachdb() {
1086            let container = cockroach_db::CockroachDb::default()
1087                .with_platform(LINUX_AMD64)
1088                .start()
1089                .await
1090                .unwrap();
1091            let host = container.get_host().await.unwrap();
1092            let port = container.get_host_port_ipv4(26257).await.unwrap();
1093            let uri = format!("postgres://root@{host}:{port}/defaultdb?sslmode=disable");
1094            run_shared_datastore_test("cockroachdb", &uri);
1095        }
1096
1097        #[tokio::test]
1098        async fn datastore_shared_mysql() {
1099            let container = mysql::Mysql::default()
1100                .with_platform(LINUX_AMD64)
1101                .start()
1102                .await
1103                .unwrap();
1104            let host = container.get_host().await.unwrap();
1105            let port = container.get_host_port_ipv4(3306).await.unwrap();
1106            // MySQL: user@tcp(host:port)/db format; parseTime=true required by SpiceDB
1107            let uri = format!("root@tcp({host}:{port})/test?parseTime=true");
1108            run_shared_datastore_test("mysql", &uri);
1109        }
1110
1111        #[tokio::test]
1112        async fn datastore_shared_spanner() {
1113            // roryq/spanner-emulator: creates instance + database on startup via env vars (no gcloud exec)
1114            // Call GenericImage methods (with_exposed_port, with_wait_for) before ImageExt methods (with_platform, with_env_var)
1115            let container = GenericImage::new("roryq/spanner-emulator", "latest")
1116                .with_exposed_port(9010u16.tcp())
1117                .with_wait_for(WaitFor::seconds(5))
1118                .with_platform(LINUX_AMD64)
1119                .with_env_var("SPANNER_PROJECT_ID", "test-project")
1120                .with_env_var("SPANNER_INSTANCE_ID", "test-instance")
1121                .with_env_var("SPANNER_DATABASE_ID", "test-db")
1122                .start()
1123                .await
1124                .unwrap();
1125            let host = container.get_host().await.unwrap();
1126            let port = container.get_host_port_ipv4(9010u16.tcp()).await.unwrap();
1127            let emulator_host = format!("{host}:{port}");
1128            let uri = "projects/test-project/instances/test-instance/databases/test-db".to_string();
1129
1130            run_migrate(
1131                "spanner",
1132                &uri,
1133                &[("datastore-spanner-emulator-host", &emulator_host)],
1134            )
1135            .expect("migration must succeed");
1136
1137            let opts = StartOptions {
1138                datastore: Some("spanner".into()),
1139                datastore_uri: Some(uri),
1140                spanner_emulator_host: Some(emulator_host),
1141                ..Default::default()
1142            };
1143
1144            let schema = TEST_SCHEMA;
1145            let db1 = EmbeddedSpiceDB::start_with_schema(schema, &[], Some(&opts)).unwrap();
1146            let db2 = EmbeddedSpiceDB::start_with_schema(schema, &[], Some(&opts)).unwrap();
1147
1148            db1.permissions()
1149                .write_relationships(&WriteRelationshipsRequest {
1150                    updates: vec![RelationshipUpdate {
1151                        operation: Operation::Touch as i32,
1152                        relationship: Some(rel("document:shared", "reader", "user:alice")),
1153                    }],
1154                    optional_preconditions: vec![],
1155                    optional_transaction_metadata: None,
1156                })
1157                .unwrap();
1158
1159            let r = db2
1160                .permissions()
1161                .check_permission(&check_req("document:shared", "read", "user:alice"))
1162                .unwrap();
1163            assert_eq!(r.permissionship, Permissionship::HasPermission as i32);
1164        }
1165    }
1166}