feat: Add working workspace #2

Manually merged
day01 merged 1 commits from feat/0.6-working-workspace into develop 2026-08-30 19:59:17 +00:00
53 changed files with 4579 additions and 44 deletions
+131
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -1,61 +1,64 @@
[package]
name = "syncode-collab"
description = "SynCode collaboration plane"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[workspace]
members = [
".",
"crates/collab-api-graphql",
"crates/collab-api-grpc",
"crates/collab-application",
"crates/collab-model",
"crates/collab-store",
]
resolver = "3"

[workspace.package]
version = "0.6.0"
edition = "2024"
rust-version = "1.95"
license = "MIT"
repository = "https://syncode.sh/syncode/collab"

[workspace.lints.rust]
unsafe_code = "forbid"

[workspace.lints.clippy]
expect_used = "deny"
panic = "deny"
unwrap_used = "deny"

[dependencies]
async-graphql-axum = "7.2.1"
axum = "0.8.9"
clap = { version = "4.6.4", features = ["derive", "env"] }
hex = "0.4.3"
hmac = "0.13.0"
serde_json = "1.0.149"
sha2 = "0.11.0"
syncode-collab-api-graphql = { path = "crates/collab-api-graphql" }
syncode-collab-api-grpc = { path = "crates/collab-api-grpc" }
syncode-collab-application = { path = "crates/collab-application" }
syncode-collab-model = { path = "crates/collab-model" }
syncode-collab-store = { path = "crates/collab-store" }
thiserror = "2.0.19"
tokio = { version = "1.49.0", features = ["macros", "net", "rt-multi-thread", "signal", "time"] }
tonic = "0.14.6"

[dev-dependencies]
syncode-collab-api-grpc = { path = "crates/collab-api-grpc" }
tokio = { version = "1.49.0", features = ["macros", "net", "rt-multi-thread"] }
tokio-stream = { version = "0.1.18", features = ["net"] }
tonic = "0.14.6"

[lints]
workspace = true
[package]
name = "syncode-collab"
description = "SynCode collaboration plane"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[workspace]
members = [
".",
"crates/collab-api-graphql",
"crates/collab-api-grpc",
"crates/collab-application",
"crates/collab-model",
"crates/collab-store",
]
resolver = "3"

[workspace.package]
version = "0.6.0"
edition = "2024"
rust-version = "1.95"
license = "MIT"
repository = "https://syncode.sh/syncode/collab"

[workspace.lints.rust]
unsafe_code = "forbid"

[workspace.lints.clippy]
expect_used = "deny"
panic = "deny"
unwrap_used = "deny"

[dependencies]
async-graphql = "7.2.1"
async-graphql-axum = "7.2.1"
axum = "0.8.9"
clap = { version = "4.6.4", features = ["derive", "env"] }
hex = "0.4.3"
hmac = "0.13.0"
serde_json = "1.0.149"
sha2 = "0.11.0"
syncode-collab-api-graphql = { path = "crates/collab-api-graphql" }
syncode-collab-api-grpc = { path = "crates/collab-api-grpc" }
syncode-collab-application = { path = "crates/collab-application" }
syncode-collab-model = { path = "crates/collab-model" }
syncode-collab-store = { path = "crates/collab-store" }
thiserror = "2.0.19"
tokio = { version = "1.49.0", features = ["macros", "net", "rt-multi-thread", "signal", "time"] }
tonic = "0.14.6"
tower-http = { version = "0.6.8", features = ["cors"] }
uuid = { version = "1.24.0", features = ["v4"] }

[dev-dependencies]
syncode-collab-api-grpc = { path = "crates/collab-api-grpc" }
tokio = { version = "1.49.0", features = ["macros", "net", "rt-multi-thread"] }
tokio-stream = { version = "0.1.18", features = ["net"] }
tonic = "0.14.6"

[lints]
workspace = true
+3
View File
@@ -1,44 +1,47 @@
# syncode-collab

The SynCode collaboration plane. It is the authority for Workspaces, Projects,
Issues, pull requests, review, Documents, Versions, webhooks, the activity feed
and the Inbox.

Collaboration keeps stable `repository_id` and `principal_id` values but never
joins another plane's database. Facts about refs, tags, principals and checks
arrive as versioned events through a deduplicating inbox; decisions and state
changes leave as commands over typed ports. There is no fallback path, no
dual-write and no read of a foreign store.

## Layout

| Crate | Role |
| --- | --- |
| `collab-model` | domain types; free of transport, storage and runtime |
| `collab-application` | use cases and ports; free of transport and storage |
| `collab-store` | PostgreSQL state, migrations, inbox and outbox |
| `collab-api-graphql` | typed GraphQL surface for the front |
| `collab-api-grpc` | versioned gRPC surface for `syn` |

## Running

```sh
export SYNCODE_COLLAB_DATABASE_URL=postgres://collab:collab@localhost:5432/collab
export SYNCODE_COLLAB_EVENT_SECRET=replace-with-a-shared-secret
cargo run --bin syncode-collab
```

Migrations are applied on start, so an empty database is a valid starting point.
gRPC listens on `8300` and HTTP on `8301`.

## Contracts

`crates/collab-api-graphql/schema.graphql` is the committed GraphQL contract.
Regenerate it after changing the schema and commit the result:

```sh
cargo run --bin collab_schema > crates/collab-api-graphql/schema.graphql
```

The gRPC contract lives in `crates/collab-api-grpc/proto/collab.proto` and is
generated at build time. CI fails when either contract drifts from its source.
# syncode-collab

The SynCode collaboration plane. It is the authority for Workspaces, Projects,
Issues, pull requests, review, Documents, Versions, webhooks, the activity feed
and the Inbox.

Collaboration keeps stable `repository_id` and `principal_id` values but never
joins another plane's database. Facts about refs, tags, principals and checks
arrive as versioned events through a deduplicating inbox; decisions and state
changes leave as commands over typed ports. There is no fallback path, no
dual-write and no read of a foreign store.

## Layout

| Crate | Role |
| --- | --- |
| `collab-model` | domain types; free of transport, storage and runtime |
| `collab-application` | use cases and ports; free of transport and storage |
| `collab-store` | PostgreSQL state, migrations, inbox and outbox |
| `collab-api-graphql` | typed GraphQL surface for the front |
| `collab-api-grpc` | versioned gRPC surface for `syn` |

## Running

```sh
export SYNCODE_COLLAB_DATABASE_URL=postgres://collab:collab@localhost:5432/collab
export SYNCODE_COLLAB_EVENT_SECRET=replace-with-a-shared-secret
export SYNCODE_IDENTITY_GRPC_URL=http://127.0.0.1:8190
export SYNCODE_IDENTITY_SHARED_SECRET=replace-with-the-identity-secret
export SYNCODE_COLLAB_CORS_ORIGINS=http://127.0.0.1:8081
cargo run --bin syncode-collab
```

Migrations are applied on start, so an empty database is a valid starting point.
gRPC listens on `8300` and HTTP on `8301`.

## Contracts

`crates/collab-api-graphql/schema.graphql` is the committed GraphQL contract.
Regenerate it after changing the schema and commit the result:

```sh
cargo run --bin collab_schema > crates/collab-api-graphql/schema.graphql
```

The gRPC contract lives in `crates/collab-api-grpc/proto/collab.proto` and is
generated at build time. CI fails when either contract drifts from its source.
+9
View File
@@ -1,32 +1,41 @@
use clap::Parser;

#[derive(Clone, Debug, Parser)]
#[command(name = "syncode-collab", about = "SynCode collaboration plane")]
pub struct Config {
#[arg(
long,
env = "SYNCODE_COLLAB_LISTEN_GRPC",
default_value = "0.0.0.0:8300"
)]
pub listen_grpc: String,

#[arg(
long,
env = "SYNCODE_COLLAB_LISTEN_HTTP",
default_value = "0.0.0.0:8301"
)]
pub listen_http: String,

#[arg(long, env = "SYNCODE_COLLAB_DATABASE_URL")]
pub database_url: String,

#[arg(
long,
env = "SYNCODE_COLLAB_DATABASE_MAX_CONNECTIONS",
default_value_t = 16
)]
pub database_max_connections: u32,

#[arg(long, env = "SYNCODE_COLLAB_EVENT_SECRET", hide_env_values = true)]
pub event_secret: String,
}
use clap::Parser;

#[derive(Clone, Debug, Parser)]
#[command(name = "syncode-collab", about = "SynCode collaboration plane")]
pub struct Config {
#[arg(
long,
env = "SYNCODE_COLLAB_LISTEN_GRPC",
default_value = "0.0.0.0:8300"
)]
pub listen_grpc: String,

#[arg(
long,
env = "SYNCODE_COLLAB_LISTEN_HTTP",
default_value = "0.0.0.0:8301"
)]
pub listen_http: String,

#[arg(long, env = "SYNCODE_COLLAB_DATABASE_URL")]
pub database_url: String,

#[arg(
long,
env = "SYNCODE_COLLAB_DATABASE_MAX_CONNECTIONS",
default_value_t = 16
)]
pub database_max_connections: u32,

#[arg(long, env = "SYNCODE_COLLAB_EVENT_SECRET", hide_env_values = true)]
pub event_secret: String,

#[arg(long, env = "SYNCODE_IDENTITY_GRPC_URL")]
pub identity_grpc_url: String,

#[arg(long, env = "SYNCODE_IDENTITY_SHARED_SECRET", hide_env_values = true)]
pub identity_shared_secret: String,

#[arg(long, env = "SYNCODE_COLLAB_CORS_ORIGINS", value_delimiter = ',')]
pub cors_origins: Vec<String>,
}
+47 -6
View File
@@ -1,97 +1,138 @@
use async_graphql_axum::GraphQL;
use axum::body::Bytes;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::{get, post};
use axum::{Router, extract::State};
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use std::sync::Arc;
use syncode_collab_application::{Acceptance, EventInbox, InboxError, StoreError, accept};
use syncode_collab_model::Envelope;

#[derive(Clone)]
struct HttpState {
inbox: Arc<dyn EventInbox>,
event_secret: String,
}

pub fn router(inbox: Arc<dyn EventInbox>, event_secret: String) -> Router {
Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/events", post(receive_event))
.route_service(
"/graphql",
GraphQL::new(syncode_collab_api_graphql::build()),
)
.with_state(HttpState {
inbox,
event_secret,
})
}

async fn receive_event(
State(state): State<HttpState>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
if !valid_signature(&headers, &body, &state.event_secret) {
return StatusCode::UNAUTHORIZED;
}
let Ok(envelope) = serde_json::from_slice::<Envelope>(&body) else {
return StatusCode::BAD_REQUEST;
};
if headers
.get("x-syncode-event")
.and_then(|value| value.to_str().ok())
!= Some(envelope.message_type.as_str())
|| headers
.get("x-syncode-delivery")
.and_then(|value| value.to_str().ok())
!= Some(envelope.message_id.to_string().as_str())
{
return StatusCode::BAD_REQUEST;
}
match accept(state.inbox.as_ref(), &envelope).await {
Ok(Acceptance::Applied) => StatusCode::NO_CONTENT,
Ok(Acceptance::AlreadyApplied) => StatusCode::OK,
Err(InboxError::Store(StoreError::Unavailable(_))) => StatusCode::SERVICE_UNAVAILABLE,
Err(InboxError::Store(StoreError::Rejected(_))) => StatusCode::BAD_REQUEST,
Err(_) => StatusCode::BAD_REQUEST,
}
}

fn valid_signature(headers: &HeaderMap, body: &[u8], secret: &str) -> bool {
let Some(signature) = headers
.get("x-syncode-signature")
.and_then(|value| value.to_str().ok())
.and_then(|value| hex::decode(value).ok())
else {
return false;
};
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else {
return false;
};
mac.update(body);
mac.verify_slice(&signature).is_ok()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn verifies_the_raw_event_body() -> Result<(), Box<dyn std::error::Error>> {
let body = br#"{"message_id":"00000000-0000-0000-0000-000000000000"}"#;
let mut mac = Hmac::<Sha256>::new_from_slice(b"secret")?;
mac.update(body);
let mut headers = HeaderMap::new();
headers.insert(
"x-syncode-signature",
hex::encode(mac.finalize().into_bytes()).parse()?,
);

assert!(valid_signature(&headers, body, "secret"));
assert!(!valid_signature(&headers, b"changed", "secret"));
Ok(())
}
}
use async_graphql_axum::{GraphQLRequest, GraphQLResponse};
use axum::body::Bytes;
use axum::http::{HeaderMap, StatusCode};
use axum::routing::{get, post};
use axum::{Router, extract::State};
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
use std::sync::Arc;
use syncode_collab_application::Collaboration;
use syncode_collab_application::{Acceptance, EventInbox, InboxError, StoreError, accept};
use syncode_collab_model::Envelope;

#[derive(Clone)]
struct HttpState {
inbox: Arc<dyn EventInbox>,
event_secret: String,
schema: syncode_collab_api_graphql::CollabSchema,
}

pub fn router(
inbox: Arc<dyn EventInbox>,
collaboration: Arc<Collaboration>,
event_secret: String,
) -> Router {
Router::new()
.route("/healthz", get(|| async { "ok" }))
.route("/events", post(receive_event))
.route("/graphql", post(graphql))
.with_state(HttpState {
inbox,
event_secret,
schema: syncode_collab_api_graphql::build(collaboration),
})
}

async fn graphql(
State(state): State<HttpState>,
headers: HeaderMap,
request: GraphQLRequest,
) -> GraphQLResponse {
let token = match session_token(&headers) {
Ok(token) => token.map(ToOwned::to_owned),
Err(()) => {
return async_graphql::Response::from_errors(vec![async_graphql::ServerError::new(
"invalid cookie header",
None,
)])
.into();
}
};
state
.schema
.execute(
request
.into_inner()
.data(syncode_collab_api_graphql::SessionToken(token)),
)
.await
.into()
}

fn session_token(headers: &HeaderMap) -> Result<Option<&str>, ()> {
let Some(value) = headers.get(axum::http::header::COOKIE) else {
return Ok(None);
};
let value = value.to_str().map_err(|_| ())?;
Ok(value
.split(';')
.map(str::trim)
.find_map(|pair| pair.strip_prefix("syncode_identity_session=")))
}

async fn receive_event(
State(state): State<HttpState>,
headers: HeaderMap,
body: Bytes,
) -> StatusCode {
if !valid_signature(&headers, &body, &state.event_secret) {
return StatusCode::UNAUTHORIZED;
}
let Ok(envelope) = serde_json::from_slice::<Envelope>(&body) else {
return StatusCode::BAD_REQUEST;
};
if headers
.get("x-syncode-event")
.and_then(|value| value.to_str().ok())
!= Some(envelope.message_type.as_str())
|| headers
.get("x-syncode-delivery")
.and_then(|value| value.to_str().ok())
!= Some(envelope.message_id.to_string().as_str())
{
return StatusCode::BAD_REQUEST;
}
match accept(state.inbox.as_ref(), &envelope).await {
Ok(Acceptance::Applied) => StatusCode::NO_CONTENT,
Ok(Acceptance::AlreadyApplied) => StatusCode::OK,
Err(InboxError::Store(StoreError::Unavailable(_))) => StatusCode::SERVICE_UNAVAILABLE,
Err(InboxError::Store(StoreError::Rejected(_))) => StatusCode::BAD_REQUEST,
Err(_) => StatusCode::BAD_REQUEST,
}
}

fn valid_signature(headers: &HeaderMap, body: &[u8], secret: &str) -> bool {
let Some(signature) = headers
.get("x-syncode-signature")
.and_then(|value| value.to_str().ok())
.and_then(|value| hex::decode(value).ok())
else {
return false;
};
let Ok(mut mac) = Hmac::<Sha256>::new_from_slice(secret.as_bytes()) else {
return false;
};
mac.update(body);
mac.verify_slice(&signature).is_ok()
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn verifies_the_raw_event_body() -> Result<(), Box<dyn std::error::Error>> {
let body = br#"{"message_id":"00000000-0000-0000-0000-000000000000"}"#;
let mut mac = Hmac::<Sha256>::new_from_slice(b"secret")?;
mac.update(body);
let mut headers = HeaderMap::new();
headers.insert(
"x-syncode-signature",
hex::encode(mac.finalize().into_bytes()).parse()?,
);

assert!(valid_signature(&headers, body, "secret"));
assert!(!valid_signature(&headers, b"changed", "secret"));
Ok(())
}
}
+1
View File
@@ -1,6 +1,7 @@
pub mod config;
pub mod http;
pub mod runtime;

pub use config::Config;
pub use runtime::{RuntimeError, run};
pub mod config;
pub mod http;
pub mod identity;
pub mod runtime;

pub use config::Config;
pub use runtime::{RuntimeError, run};
+41 -3
View File
@@ -1,54 +1,92 @@
use std::sync::Arc;
use syncode_collab_api_grpc::CollabService;
use syncode_collab_store::{PostgresInbox, connect, migrate};

use crate::config::Config;
use crate::http::router;

pub async fn run(config: Config) -> Result<(), RuntimeError> {
if config.event_secret.is_empty() {
return Err(RuntimeError::Configuration(
"SYNCODE_COLLAB_EVENT_SECRET must not be empty".to_owned(),
));
}
let pool = connect(&config.database_url, config.database_max_connections)
.await
.map_err(|error| RuntimeError::Store(error.to_string()))?;
migrate(&pool)
.await
.map_err(|error| RuntimeError::Store(error.to_string()))?;

let http = tokio::net::TcpListener::bind(&config.listen_http)
.await
.map_err(|error| RuntimeError::Listen(config.listen_http.clone(), error.to_string()))?;
let grpc = config
.listen_grpc
.parse()
.map_err(|_| RuntimeError::Address(config.listen_grpc.clone()))?;

let inbox = Arc::new(PostgresInbox::new(pool));
let serve_http = axum::serve(http, router(inbox, config.event_secret));
let serve_grpc = tonic::transport::Server::builder()
.add_service(CollabService.into_server())
.serve(grpc);

tokio::select! {
result = serve_http => result.map_err(|error| RuntimeError::Serve(error.to_string())),
result = serve_grpc => result.map_err(|error| RuntimeError::Serve(error.to_string())),
_ = tokio::signal::ctrl_c() => Ok(()),
}
}

#[derive(Debug, thiserror::Error)]
pub enum RuntimeError {
#[error("invalid collaboration configuration: {0}")]
Configuration(String),
#[error("{0} is not a valid socket address")]
Address(String),
#[error("cannot listen on {0}: {1}")]
Listen(String, String),
#[error("collaboration store failed: {0}")]
Store(String),
#[error("serving failed: {0}")]
Serve(String),
}
use std::sync::Arc;
use syncode_collab_api_grpc::CollabService;
use syncode_collab_application::Collaboration;
use syncode_collab_store::{PostgresCollaboration, PostgresInbox, connect, migrate};
use tower_http::cors::{AllowOrigin, CorsLayer};

use crate::config::Config;
use crate::http::router;
use crate::identity::IdentityClientAccess;

pub async fn run(config: Config) -> Result<(), RuntimeError> {
if config.event_secret.is_empty() {
return Err(RuntimeError::Configuration(
"SYNCODE_COLLAB_EVENT_SECRET must not be empty".to_owned(),
));
}
if config.identity_shared_secret.is_empty() {
return Err(RuntimeError::Configuration(
"SYNCODE_IDENTITY_SHARED_SECRET must not be empty".to_owned(),
));
}
let pool = connect(&config.database_url, config.database_max_connections)
.await
.map_err(|error| RuntimeError::Store(error.to_string()))?;
migrate(&pool)
.await
.map_err(|error| RuntimeError::Store(error.to_string()))?;

let http = tokio::net::TcpListener::bind(&config.listen_http)
.await
.map_err(|error| RuntimeError::Listen(config.listen_http.clone(), error.to_string()))?;
let grpc = config
.listen_grpc
.parse()
.map_err(|_| RuntimeError::Address(config.listen_grpc.clone()))?;

let identity = Arc::new(
IdentityClientAccess::connect(config.identity_grpc_url, config.identity_shared_secret)
.await
.map_err(|error| RuntimeError::Identity(error.to_string()))?,
);
let collaboration = Arc::new(Collaboration::new(
Arc::new(PostgresCollaboration::new(pool.clone())),
identity,
));
let inbox = Arc::new(PostgresInbox::new(pool));
let mut http_router = router(inbox, Arc::clone(&collaboration), config.event_secret);
if !config.cors_origins.is_empty() {
let allowed = config.cors_origins;
http_router = http_router.layer(
CorsLayer::new()
.allow_credentials(true)
.allow_methods([
axum::http::Method::GET,
axum::http::Method::POST,
axum::http::Method::OPTIONS,
])
.allow_headers([axum::http::header::CONTENT_TYPE])
.allow_origin(AllowOrigin::predicate(move |origin, _| {
origin
.to_str()
.is_ok_and(|origin| allowed.iter().any(|value| value == origin))
})),
);
}
let serve_http = axum::serve(http, http_router);
let serve_grpc = tonic::transport::Server::builder()
.add_service(CollabService::new(collaboration).into_server())
.serve(grpc);

tokio::select! {
result = serve_http => result.map_err(|error| RuntimeError::Serve(error.to_string())),
result = serve_grpc => result.map_err(|error| RuntimeError::Serve(error.to_string())),
_ = tokio::signal::ctrl_c() => Ok(()),
}
}

#[derive(Debug, thiserror::Error)]
pub enum RuntimeError {
#[error("invalid collaboration configuration: {0}")]
Configuration(String),
#[error("{0} is not a valid socket address")]
Address(String),
#[error("cannot listen on {0}: {1}")]
Listen(String, String),
#[error("collaboration store failed: {0}")]
Store(String),
#[error("identity integration failed: {0}")]
Identity(String),
#[error("serving failed: {0}")]
Serve(String),
}
+1 -1
View File
@@ -1,27 +1,27 @@
use syncode_collab_api_grpc::CollabService;
use syncode_collab_api_grpc::proto::GetPlaneInfoRequest;
use syncode_collab_api_grpc::proto::collab_client::CollabClient;
use tokio::net::TcpListener;
use tokio_stream::wrappers::TcpListenerStream;

/// The gRPC contract is generated from `collab.proto` at build time. This test
/// proves the generated server and client agree and the service answers.
#[tokio::test]
async fn serves_plane_info() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let address = listener.local_addr()?;

tokio::spawn(async move {
let _ = tonic::transport::Server::builder()
.add_service(CollabService.into_server())
.serve_with_incoming(TcpListenerStream::new(listener))
.await;
});

let mut client = CollabClient::connect(format!("http://{address}")).await?;
let info = client.get_plane_info(GetPlaneInfoRequest {}).await?;

assert_eq!(env!("CARGO_PKG_VERSION"), info.get_ref().version);
assert_eq!(1, info.get_ref().envelope_protocol_version);
Ok(())
}
use syncode_collab_api_grpc::CollabService;
use syncode_collab_api_grpc::proto::GetPlaneInfoRequest;
use syncode_collab_api_grpc::proto::collab_client::CollabClient;
use tokio::net::TcpListener;
use tokio_stream::wrappers::TcpListenerStream;

/// The gRPC contract is generated from `collab.proto` at build time. This test
/// proves the generated server and client agree and the service answers.
#[tokio::test]
async fn serves_plane_info() -> Result<(), Box<dyn std::error::Error>> {
let listener = TcpListener::bind("127.0.0.1:0").await?;
let address = listener.local_addr()?;

tokio::spawn(async move {
let _ = tonic::transport::Server::builder()
.add_service(CollabService::default().into_server())
.serve_with_incoming(TcpListenerStream::new(listener))
.await;
});

let mut client = CollabClient::connect(format!("http://{address}")).await?;
let info = client.get_plane_info(GetPlaneInfoRequest {}).await?;

assert_eq!(env!("CARGO_PKG_VERSION"), info.get_ref().version);
assert_eq!(1, info.get_ref().envelope_protocol_version);
Ok(())
}
+1
View File
@@ -1,19 +1,20 @@
[package]
name = "syncode-collab-api-graphql"
description = "GraphQL surface of the SynCode collaboration plane"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[dependencies]
async-graphql = "7.2.1"
async-graphql-axum = "7.2.1"
axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio"] }
syncode-collab-application = { path = "../collab-application" }
syncode-collab-model = { path = "../collab-model" }

[lints]
workspace = true
[package]
name = "syncode-collab-api-graphql"
description = "GraphQL surface of the SynCode collaboration plane"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[dependencies]
async-graphql = "7.2.1"
async-graphql-axum = "7.2.1"
axum = { version = "0.8.9", default-features = false, features = ["http1", "tokio"] }
syncode-collab-application = { path = "../collab-application" }
syncode-collab-model = { path = "../collab-model" }
uuid = "1.24.0"

[lints]
workspace = true
+168 -6
View File
@@ -1,23 +1,185 @@
type Query {
"""
Version of the collaboration plane serving this schema.
"""
version: String!
"""
Protocol version of the cross-plane event envelope this service accepts.
"""
envelopeProtocolVersion: Int!
}

"""
Directs the executor to include this field or fragment only when the `if` argument is true.
"""
directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
"""
Directs the executor to skip this field or fragment when the `if` argument is true.
"""
directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
schema {
query: Query
}

input AttachRepositoryInput {
workspaceId: ID!
owner: String!
name: String!
role: RepositoryRole!
branch: String!
}

type Checkout {
workspace: Workspace!
environment: String!
repositories: [CheckoutRepository!]!
omittedRepositories: Int!
}

type CheckoutRepository {
repositoryId: ID!
owner: String!
name: String!
role: RepositoryRole!
branch: String!
}

input ConfigureEnvironmentInput {
workspaceId: ID!
name: String!
defaultSourceBranch: String!
infraBranch: String
}

input CreateIssueInput {
projectId: ID!
title: String!
description: String! = ""
priority: IssuePriority!
}

input CreateProjectInput {
workspaceId: ID!
key: String!
name: String!
slug: String!
description: String! = ""
kind: ProjectKind!
}

input CreateVersionInput {
projectId: ID!
name: String!
slug: String!
}

input CreateWorkspaceInput {
ownerKind: OwnerKind!
ownerId: ID!
name: String!
slug: String!
defaultEnvironment: String!
defaultSourceBranch: String!
}

type Document {
id: ID!
workspaceId: ID!
projectId: ID
path: String!
revisionId: ID!
content: String!
}

type Environment {
name: String!
defaultSourceBranch: String!
infraBranch: String
}

type Issue {
id: ID!
projectId: ID!
number: Int!
title: String!
description: String!
statusId: ID!
typeId: ID!
priority: IssuePriority!
version: Int!
}

enum IssuePriority {
NONE
LOW
NORMAL
HIGH
URGENT
}

type Mutation {
createWorkspace(input: CreateWorkspaceInput!): Workspace!
attachRepository(input: AttachRepositoryInput!): Boolean!
configureEnvironment(input: ConfigureEnvironmentInput!): Environment!
createProject(input: CreateProjectInput!): Project!
putDocument(input: PutDocumentInput!): Document!
createIssue(input: CreateIssueInput!): Issue!
createVersion(input: CreateVersionInput!): Version!
}

enum OwnerKind {
USER
ORGANIZATION
}

type Project {
id: ID!
workspaceId: ID!
key: String!
name: String!
slug: String!
description: String!
kind: ProjectKind!
lifecycle: String!
version: Int!
}

enum ProjectKind {
CONTINUOUS
FINITE
}

input PutDocumentInput {
workspaceId: ID!
projectId: ID
path: String!
content: String!
baseRevisionId: ID
}

type Query {
version: String!
envelopeProtocolVersion: Int!
workspaces: [Workspace!]!
workspace(id: ID!): Workspace!
checkout(workspaceId: ID!, environment: String, partial: Boolean): Checkout!
projects(workspaceId: ID!): [Project!]!
environments(workspaceId: ID!): [Environment!]!
documents(workspaceId: ID!, projectId: ID): [Document!]!
issues(projectId: ID!): [Issue!]!
versions(projectId: ID!): [Version!]!
}

enum RepositoryRole {
SOURCE
INFRA
}

type Version {
id: ID!
projectId: ID!
name: String!
slug: String!
state: String!
}

type Workspace {
id: ID!
ownerKind: OwnerKind!
ownerId: ID!
name: String!
slug: String!
defaultEnvironment: String!
version: Int!
}

"""
Directs the executor to include this field or fragment only when the `if` argument is true.
"""
directive @include(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
"""
Directs the executor to skip this field or fragment when the `if` argument is true.
"""
directive @skip(if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT
schema {
query: Query
mutation: Mutation
}

+1
View File
@@ -1,24 +1,25 @@
[package]
name = "syncode-collab-api-grpc"
description = "Versioned gRPC API of the SynCode collaboration plane"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[dependencies]
prost = "0.14.4"
syncode-collab-application = { path = "../collab-application" }
syncode-collab-model = { path = "../collab-model" }
tonic = "0.14.6"
tonic-prost = "0.14.6"

[build-dependencies]
prost-build = "0.14.4"
protoc-bin-vendored = "3.2.0"
tonic-prost-build = "0.14.6"

[lints]
workspace = true
[package]
name = "syncode-collab-api-grpc"
description = "Versioned gRPC API of the SynCode collaboration plane"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[dependencies]
prost = "0.14.4"
syncode-collab-application = { path = "../collab-application" }
syncode-collab-model = { path = "../collab-model" }
tonic = "0.14.6"
tonic-prost = "0.14.6"
uuid = "1.24.0"

[build-dependencies]
prost-build = "0.14.4"
protoc-bin-vendored = "3.2.0"
tonic-prost-build = "0.14.6"

[lints]
workspace = true
+1 -1
View File
@@ -1,13 +1,13 @@
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
let mut config = prost_build::Config::new();
config.protoc_executable(protoc_bin_vendored::protoc_bin_path()?);
tonic_prost_build::configure().compile_with_config(
config,
&["proto/collab.proto"],
&["proto"],
)?;
println!("cargo:rerun-if-changed=proto");
Ok(())
}
use std::error::Error;

fn main() -> Result<(), Box<dyn Error>> {
let mut config = prost_build::Config::new();
config.protoc_executable(protoc_bin_vendored::protoc_bin_path()?);
tonic_prost_build::configure().compile_with_config(
config,
&["proto/collab.proto", "proto/identity.proto"],
&["proto"],
)?;
println!("cargo:rerun-if-changed=proto");
Ok(())
}
+1
View File
@@ -1,18 +1,19 @@
[package]
name = "syncode-collab-model"
description = "Domain model of the SynCode collaboration plane"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[dependencies]
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0.19"
uuid = { version = "1.24.0", features = ["serde", "v4"] }

[lints]
workspace = true
[package]
name = "syncode-collab-model"
description = "Domain model of the SynCode collaboration plane"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[dependencies]
chrono = { version = "0.4.42", features = ["serde"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
thiserror = "2.0.19"
uuid = { version = "1.24.0", features = ["serde", "v4"] }

[lints]
workspace = true
+2 -1
View File
@@ -1,27 +1,28 @@
[package]
name = "syncode-collab-store"
description = "PostgreSQL state store of the SynCode collaboration plane"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[dependencies]
async-trait = "0.1.89"
serde_json = "1.0"
sqlx = { version = "0.8.6", default-features = false, features = ["json", "macros", "migrate", "postgres", "runtime-tokio", "tls-rustls", "uuid"] }
syncode-collab-application = { path = "../collab-application" }
syncode-collab-model = { path = "../collab-model" }
thiserror = "2.0.19"
uuid = "1.24.0"

[dev-dependencies]
serde_json = "1.0"
sqlx = { version = "0.8.6", default-features = false, features = ["postgres", "runtime-tokio", "tls-rustls"] }
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
uuid = { version = "1.24.0", features = ["v4"] }

[lints]
workspace = true
[package]
name = "syncode-collab-store"
description = "PostgreSQL state store of the SynCode collaboration plane"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish = false

[dependencies]
async-trait = "0.1.89"
chrono = "0.4.42"
serde_json = "1.0"
sqlx = { version = "0.8.6", default-features = false, features = ["chrono", "json", "macros", "migrate", "postgres", "runtime-tokio", "tls-rustls", "uuid"] }
syncode-collab-application = { path = "../collab-application" }
syncode-collab-model = { path = "../collab-model" }
thiserror = "2.0.19"
uuid = "1.24.0"

[dev-dependencies]
serde_json = "1.0"
sqlx = { version = "0.8.6", default-features = false, features = ["postgres", "runtime-tokio", "tls-rustls"] }
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
uuid = { version = "1.24.0", features = ["v4"] }

[lints]
workspace = true
+5 -1
View File
@@ -1,3 +1,7 @@
pub mod schema;

pub use schema::{CollabSchema, build, sdl};
mod context;
mod mutation;
mod query;
pub mod schema;
mod types;

pub use schema::{CollabSchema, SessionToken, build, sdl};
+19 -21
View File
@@ -1,26 +1,24 @@
use async_graphql::{EmptyMutation, EmptySubscription, Object, Schema};

pub type CollabSchema = Schema<Query, EmptyMutation, EmptySubscription>;

pub struct Query;

#[Object]
impl Query {
/// Version of the collaboration plane serving this schema.
async fn version(&self) -> &'static str {
env!("CARGO_PKG_VERSION")
}

/// Protocol version of the cross-plane event envelope this service accepts.
async fn envelope_protocol_version(&self) -> u32 {
syncode_collab_model::envelope::PROTOCOL_VERSION.0
}
}

pub fn build() -> CollabSchema {
Schema::build(Query, EmptyMutation, EmptySubscription).finish()
}

pub fn sdl() -> String {
build().sdl()
}
use std::sync::Arc;

use async_graphql::{EmptySubscription, Schema};
use syncode_collab_application::Collaboration;

use crate::mutation::Mutation;
use crate::query::Query;

pub type CollabSchema = Schema<Query, Mutation, EmptySubscription>;

#[derive(Clone)]
pub struct SessionToken(pub Option<String>);

pub fn build(collaboration: Arc<Collaboration>) -> CollabSchema {
Schema::build(Query, Mutation, EmptySubscription)
.data(collaboration)
.finish()
}

pub fn sdl() -> String {
Schema::build(Query, Mutation, EmptySubscription)
.finish()
.sdl()
}
+231
View File
@@ -1,14 +1,245 @@
syntax = "proto3";

package syncode.collab.v1;

service Collab {
rpc GetPlaneInfo(GetPlaneInfoRequest) returns (PlaneInfo);
}

message GetPlaneInfoRequest {}

message PlaneInfo {
string version = 1;
uint32 envelope_protocol_version = 2;
}
syntax = "proto3";

package syncode.collab.v1;

service Collab {
rpc GetPlaneInfo(GetPlaneInfoRequest) returns (PlaneInfo);
rpc CreateWorkspace(CreateWorkspaceRequest) returns (Workspace);
rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse);
rpc GetWorkspace(GetWorkspaceRequest) returns (Workspace);
rpc AttachRepository(AttachRepositoryRequest) returns (OperationResponse);
rpc GetCheckout(GetCheckoutRequest) returns (Checkout);
rpc ConfigureEnvironment(ConfigureEnvironmentRequest) returns (Environment);
rpc ListEnvironments(ListEnvironmentsRequest) returns (ListEnvironmentsResponse);
rpc CreateProject(CreateProjectRequest) returns (Project);
rpc ListProjects(ListProjectsRequest) returns (ListProjectsResponse);
rpc PutDocument(PutDocumentRequest) returns (Document);
rpc ListDocuments(ListDocumentsRequest) returns (ListDocumentsResponse);
rpc CreateIssue(CreateIssueRequest) returns (Issue);
rpc ListIssues(ListIssuesRequest) returns (ListIssuesResponse);
rpc CreateVersion(CreateVersionRequest) returns (Version);
rpc ListVersions(ListVersionsRequest) returns (ListVersionsResponse);
}

message GetPlaneInfoRequest {}

message PlaneInfo {
string version = 1;
uint32 envelope_protocol_version = 2;
}

enum OwnerKind {
OWNER_KIND_UNSPECIFIED = 0;
OWNER_KIND_USER = 1;
OWNER_KIND_ORGANIZATION = 2;
}

enum RepositoryRole {
REPOSITORY_ROLE_UNSPECIFIED = 0;
REPOSITORY_ROLE_SOURCE = 1;
REPOSITORY_ROLE_INFRA = 2;
}

enum ProjectKind {
PROJECT_KIND_UNSPECIFIED = 0;
PROJECT_KIND_CONTINUOUS = 1;
PROJECT_KIND_FINITE = 2;
}

enum IssuePriority {
ISSUE_PRIORITY_UNSPECIFIED = 0;
ISSUE_PRIORITY_NONE = 1;
ISSUE_PRIORITY_LOW = 2;
ISSUE_PRIORITY_NORMAL = 3;
ISSUE_PRIORITY_HIGH = 4;
ISSUE_PRIORITY_URGENT = 5;
}

message Workspace {
string id = 1;
OwnerKind owner_kind = 2;
string owner_id = 3;
string name = 4;
string slug = 5;
string default_environment = 6;
int64 version = 7;
}

message CreateWorkspaceRequest {
string session_token = 1;
OwnerKind owner_kind = 2;
string owner_id = 3;
string name = 4;
string slug = 5;
string default_environment = 6;
string default_source_branch = 7;
}

message ListWorkspacesRequest { string session_token = 1; }
message ListWorkspacesResponse { repeated Workspace workspaces = 1; }

message GetWorkspaceRequest {
string session_token = 1;
string workspace_id = 2;
}

message AttachRepositoryRequest {
string session_token = 1;
string workspace_id = 2;
string owner = 3;
string name = 4;
RepositoryRole role = 5;
string branch = 6;
}

message OperationResponse {}

message GetCheckoutRequest {
string session_token = 1;
string workspace_id = 2;
string environment = 3;
bool partial = 4;
}

message CheckoutRepository {
string repository_id = 1;
string owner = 2;
string name = 3;
RepositoryRole role = 4;
string branch = 5;
}

message Checkout {
Workspace workspace = 1;
string environment = 2;
repeated CheckoutRepository repositories = 3;
uint32 omitted_repositories = 4;
}

message Environment {
string workspace_id = 1;
string name = 2;
string default_source_branch = 3;
string infra_branch = 4;
}

message ConfigureEnvironmentRequest {
string session_token = 1;
string workspace_id = 2;
string name = 3;
string default_source_branch = 4;
string infra_branch = 5;
}

message ListEnvironmentsRequest {
string session_token = 1;
string workspace_id = 2;
}

message ListEnvironmentsResponse { repeated Environment environments = 1; }

message Project {
string id = 1;
string workspace_id = 2;
string key = 3;
string name = 4;
string slug = 5;
string description = 6;
ProjectKind kind = 7;
string lifecycle = 8;
string default_status_id = 9;
string completion_status_id = 10;
int64 version = 11;
}

message CreateProjectRequest {
string session_token = 1;
string workspace_id = 2;
string key = 3;
string name = 4;
string slug = 5;
string description = 6;
ProjectKind kind = 7;
}

message ListProjectsRequest {
string session_token = 1;
string workspace_id = 2;
}

message ListProjectsResponse { repeated Project projects = 1; }

message Document {
string id = 1;
string workspace_id = 2;
string project_id = 3;
string path = 4;
string revision_id = 5;
string content = 6;
}

message PutDocumentRequest {
string session_token = 1;
string workspace_id = 2;
string project_id = 3;
string path = 4;
string content = 5;
string base_revision_id = 6;
}

message ListDocumentsRequest {
string session_token = 1;
string workspace_id = 2;
string project_id = 3;
}

message ListDocumentsResponse { repeated Document documents = 1; }

message Issue {
string id = 1;
string project_id = 2;
int64 number = 3;
string title = 4;
string description = 5;
string status_id = 6;
string type_id = 7;
IssuePriority priority = 8;
int64 version = 9;
}

message CreateIssueRequest {
string session_token = 1;
string project_id = 2;
string title = 3;
string description = 4;
IssuePriority priority = 5;
}

message ListIssuesRequest {
string session_token = 1;
string project_id = 2;
}

message ListIssuesResponse { repeated Issue issues = 1; }

message Version {
string id = 1;
string project_id = 2;
string name = 3;
string slug = 4;
string state = 5;
}

message CreateVersionRequest {
string session_token = 1;
string project_id = 2;
string name = 3;
string slug = 4;
}

message ListVersionsRequest {
string session_token = 1;
string project_id = 2;
}

message ListVersionsResponse { repeated Version versions = 1; }
+7
View File
@@ -1,8 +1,15 @@
pub mod service;

pub mod proto {
#![allow(clippy::doc_overindented_list_items)]
tonic::include_proto!("syncode.collab.v1");
}

pub use service::CollabService;
mod mapping;
mod operations;
pub mod service;

pub mod proto {
#![allow(clippy::doc_overindented_list_items)]
tonic::include_proto!("syncode.collab.v1");
}

pub mod identity_proto {
#![allow(clippy::doc_overindented_list_items)]
tonic::include_proto!("syncode.identity.v1");
}

pub use service::CollabService;
+156 -3
View File
@@ -1,26 +1,179 @@
use tonic::{Request, Response, Status};

use crate::proto::collab_server::{Collab, CollabServer};
use crate::proto::{GetPlaneInfoRequest, PlaneInfo};

#[derive(Default)]
pub struct CollabService;

impl CollabService {
pub fn into_server(self) -> CollabServer<Self> {
CollabServer::new(self)
}
}

#[tonic::async_trait]
impl Collab for CollabService {
async fn get_plane_info(
&self,
_: Request<GetPlaneInfoRequest>,
) -> Result<Response<PlaneInfo>, Status> {
Ok(Response::new(PlaneInfo {
version: env!("CARGO_PKG_VERSION").to_owned(),
envelope_protocol_version: syncode_collab_model::envelope::PROTOCOL_VERSION.0,
}))
}
}
use std::sync::Arc;

use tonic::{Request, Response, Status};

use crate::operations;
use crate::proto::collab_server::{Collab, CollabServer};
use crate::proto::*;
use syncode_collab_application::Collaboration;

#[derive(Clone, Default)]
pub struct CollabService {
collaboration: Option<Arc<Collaboration>>,
}

impl CollabService {
pub fn new(collaboration: Arc<Collaboration>) -> Self {
Self {
collaboration: Some(collaboration),
}
}

pub fn into_server(self) -> CollabServer<Self> {
CollabServer::new(self)
}

fn collaboration(&self) -> Result<&Collaboration, Status> {
self.collaboration
.as_deref()
.ok_or_else(|| Status::unavailable("collaboration application is not configured"))
}
}

#[tonic::async_trait]
impl Collab for CollabService {
async fn get_plane_info(
&self,
_: Request<GetPlaneInfoRequest>,
) -> Result<Response<PlaneInfo>, Status> {
Ok(Response::new(PlaneInfo {
version: env!("CARGO_PKG_VERSION").to_owned(),
envelope_protocol_version: syncode_collab_model::envelope::PROTOCOL_VERSION.0,
}))
}

async fn create_workspace(
&self,
request: Request<CreateWorkspaceRequest>,
) -> Result<Response<Workspace>, Status> {
operations::create_workspace(self.collaboration()?, request.into_inner())
.await
.map(Response::new)
}

async fn list_workspaces(
&self,
request: Request<ListWorkspacesRequest>,
) -> Result<Response<ListWorkspacesResponse>, Status> {
operations::list_workspaces(self.collaboration()?, request.into_inner())
.await
.map(Response::new)
}

async fn get_workspace(
&self,
request: Request<GetWorkspaceRequest>,
) -> Result<Response<Workspace>, Status> {
operations::get_workspace(self.collaboration()?, request.into_inner())
.await
.map(Response::new)
}

async fn attach_repository(
&self,
request: Request<AttachRepositoryRequest>,
) -> Result<Response<OperationResponse>, Status> {
operations::attach_repository(self.collaboration()?, request.into_inner())
.await
.map(Response::new)
}

async fn get_checkout(
&self,
request: Request<GetCheckoutRequest>,
) -> Result<Response<Checkout>, Status> {
operations::get_checkout(self.collaboration()?, request.into_inner())
.await
.map(Response::new)
}

async fn configure_environment(
&self,
request: Request<ConfigureEnvironmentRequest>,
) -> Result<Response<Environment>, Status> {
operations::configure_environment(self.collaboration()?, request.into_inner())
.await
.map(Response::new)
}

async fn list_environments(
&self,
request: Request<ListEnvironmentsRequest>,
) -> Result<Response<ListEnvironmentsResponse>, Status> {
operations::list_environments(self.collaboration()?, request.into_inner())
.await
.map(Response::new)
}

async fn create_project(
&self,
request: Request<CreateProjectRequest>,
) -> Result<Response<Project>, Status> {
operations::create_project(self.collaboration()?, request.into_inner())
.await
.map(Response::new)
}

async fn list_projects(
&self,
request: Request<ListProjectsRequest>,
) -> Result<Response<ListProjectsResponse>, Status> {
operations::list_projects(self.collaboration()?, request.into_inner())
.await
.map(Response::new)
}

async fn put_document(
&self,
request: Request<PutDocumentRequest>,
) -> Result<Response<Document>, Status> {
operations::put_document(self.collaboration()?, request.into_inner())
.await
.map(Response::new)
}

async fn list_documents(
&self,
request: Request<ListDocumentsRequest>,
) -> Result<Response<ListDocumentsResponse>, Status> {
operations::list_documents(self.collaboration()?, request.into_inner())
.await
.map(Response::new)
}

async fn create_issue(
&self,
request: Request<CreateIssueRequest>,
) -> Result<Response<Issue>, Status> {
operations::create_issue(self.collaboration()?, request.into_inner())
.await
.map(Response::new)
}

async fn list_issues(
&self,
request: Request<ListIssuesRequest>,
) -> Result<Response<ListIssuesResponse>, Status> {
operations::list_issues(self.collaboration()?, request.into_inner())
.await
.map(Response::new)
}

async fn create_version(
&self,
request: Request<CreateVersionRequest>,
) -> Result<Response<Version>, Status> {
operations::create_version(self.collaboration()?, request.into_inner())
.await
.map(Response::new)
}

async fn list_versions(
&self,
request: Request<ListVersionsRequest>,
) -> Result<Response<ListVersionsResponse>, Status> {
operations::list_versions(self.collaboration()?, request.into_inner())
.await
.map(Response::new)
}
}
+9
View File
@@ -1,9 +1,18 @@
pub mod inbox;
pub mod outbox;
pub mod ports;

pub use inbox::{Acceptance, InboxError, accept};
pub use outbox::{
DispatchReport, EventPublisher, OutboxMessage, OutboxQueue, PublishError, backoff, dispatch,
};
pub use ports::{EventInbox, EventOutbox, OutboxRecord, StoreError};
pub mod inbox;
pub mod outbox;
pub mod ports;
mod validation;
mod working_types;
mod working_versions;
mod working_workspace;

pub use inbox::{Acceptance, InboxError, accept};
pub use outbox::{
DispatchReport, EventPublisher, OutboxMessage, OutboxQueue, PublishError, backoff, dispatch,
};
pub use ports::{EventInbox, EventOutbox, OutboxRecord, StoreError};
pub use working_types::{
CollaborationError, ConfigureEnvironment, CreateDocument, CreateIssue, CreateProject,
CreateVersion, CreateWorkspace, Principal, PrincipalKind, RepositoryCoordinates,
};
pub use working_workspace::Collaboration;
+150 -1
View File
@@ -1,39 +1,188 @@
use async_trait::async_trait;
use syncode_collab_model::Envelope;
use uuid::Uuid;

#[derive(Clone, Debug)]
pub struct OutboxRecord {
pub message_id: Uuid,
pub message_type: String,
pub resource_id: Uuid,
pub payload: serde_json::Value,
}

/// Records a foreign fact exactly once. The implementation must write the
/// deduplication key in the same transaction as the effect it applies.
#[async_trait]
pub trait EventInbox: Send + Sync {
async fn already_applied(&self, message_id: Uuid) -> Result<bool, StoreError>;

async fn last_position(
&self,
source_node_id: &str,
resource_id: Uuid,
) -> Result<Option<(i64, i64)>, StoreError>;

async fn apply(&self, envelope: &Envelope) -> Result<(), StoreError>;
}

#[async_trait]
pub trait EventOutbox: Send + Sync {
async fn enqueue(&self, record: OutboxRecord) -> Result<(), StoreError>;
}

#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("collaboration store is unavailable: {0}")]
Unavailable(String),
#[error("collaboration store rejected the write: {0}")]
Rejected(String),
}
use async_trait::async_trait;
use syncode_collab_model::{
Document, DocumentRevision, Envelope, Environment, Issue, Project, RepositoryRole, Version,
Workspace,
};
use uuid::Uuid;

#[derive(Clone, Debug)]
pub struct OutboxRecord {
pub message_id: Uuid,
pub message_type: String,
pub resource_id: Uuid,
pub payload: serde_json::Value,
}

/// Records a foreign fact exactly once. The implementation must write the
/// deduplication key in the same transaction as the effect it applies.
#[async_trait]
pub trait EventInbox: Send + Sync {
async fn already_applied(&self, message_id: Uuid) -> Result<bool, StoreError>;

async fn last_position(
&self,
source_node_id: &str,
resource_id: Uuid,
) -> Result<Option<(i64, i64)>, StoreError>;

async fn apply(&self, envelope: &Envelope) -> Result<(), StoreError>;
}

#[async_trait]
pub trait EventOutbox: Send + Sync {
async fn enqueue(&self, record: OutboxRecord) -> Result<(), StoreError>;
}

#[async_trait]
pub trait IdentityAccess: Send + Sync {
async fn authenticate(&self, session_token: &str) -> Result<crate::Principal, AccessError>;

async fn require_organization_admin(
&self,
principal: crate::Principal,
organization_id: Uuid,
) -> Result<(), AccessError>;

async fn resolve_readable_repository(
&self,
principal: crate::Principal,
owner: &str,
name: &str,
) -> Result<crate::RepositoryCoordinates, AccessError>;

async fn repository_coordinates(
&self,
principal: crate::Principal,
repository_id: Uuid,
) -> Result<crate::RepositoryCoordinates, AccessError>;
}

#[derive(Clone, Debug)]
pub struct StoredRepository {
pub repository_id: Uuid,
pub role: RepositoryRole,
}

#[async_trait]
pub trait CollaborationStore: Send + Sync {
async fn create_workspace(
&self,
principal_id: Uuid,
command: &crate::CreateWorkspace,
) -> Result<Workspace, StoreError>;

async fn list_workspaces(&self, principal_id: Uuid) -> Result<Vec<Workspace>, StoreError>;

async fn workspace(
&self,
principal_id: Uuid,
workspace_id: Uuid,
) -> Result<Workspace, StoreError>;

async fn attach_repository(
&self,
principal_id: Uuid,
workspace_id: Uuid,
repository_id: Uuid,
role: RepositoryRole,
branch: &str,
) -> Result<(), StoreError>;

async fn repositories(
&self,
principal_id: Uuid,
workspace_id: Uuid,
) -> Result<Vec<StoredRepository>, StoreError>;

async fn environment(
&self,
principal_id: Uuid,
workspace_id: Uuid,
name: &str,
) -> Result<Environment, StoreError>;

async fn configure_environment(
&self,
principal_id: Uuid,
command: &crate::ConfigureEnvironment,
) -> Result<Environment, StoreError>;

async fn list_environments(
&self,
principal_id: Uuid,
workspace_id: Uuid,
) -> Result<Vec<Environment>, StoreError>;

async fn create_project(
&self,
principal_id: Uuid,
command: &crate::CreateProject,
) -> Result<Project, StoreError>;

async fn list_projects(
&self,
principal_id: Uuid,
workspace_id: Uuid,
) -> Result<Vec<Project>, StoreError>;

async fn write_document(
&self,
principal_id: Uuid,
command: &crate::CreateDocument,
) -> Result<(Document, DocumentRevision), StoreError>;

async fn list_documents(
&self,
principal_id: Uuid,
workspace_id: Uuid,
project_id: Option<Uuid>,
) -> Result<Vec<(Document, DocumentRevision)>, StoreError>;

async fn create_issue(
&self,
principal_id: Uuid,
command: &crate::CreateIssue,
) -> Result<Issue, StoreError>;

async fn list_issues(
&self,
principal_id: Uuid,
project_id: Uuid,
) -> Result<Vec<Issue>, StoreError>;

async fn create_version(
&self,
principal_id: Uuid,
command: &crate::CreateVersion,
) -> Result<Version, StoreError>;

async fn list_versions(
&self,
principal_id: Uuid,
project_id: Uuid,
) -> Result<Vec<Version>, StoreError>;
}

#[derive(Debug, thiserror::Error)]
pub enum AccessError {
#[error("session is not authenticated")]
Unauthenticated,
#[error("access is denied")]
Denied,
#[error("identity resource was not found")]
NotFound,
#[error("identity is unavailable: {0}")]
Unavailable(String),
}

#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("collaboration store is unavailable: {0}")]
Unavailable(String),
#[error("collaboration store rejected the write: {0}")]
Rejected(String),
#[error("collaboration resource was not found")]
NotFound,
#[error("collaboration write conflicts with current state: {0}")]
Conflict(String),
}
+15
View File
@@ -1,5 +1,20 @@
pub mod envelope;
pub mod plane;

pub use envelope::{Envelope, EnvelopeError, MessageType, ProtocolVersion, SchemaVersion};
pub use plane::Plane;
pub mod document;
pub mod envelope;
pub mod issue;
pub mod plane;
pub mod project;
pub mod version;
pub mod workspace;

pub use document::{Document, DocumentRevision, DocumentScope};
pub use envelope::{Envelope, EnvelopeError, MessageType, ProtocolVersion, SchemaVersion};
pub use issue::{Issue, IssuePriority};
pub use plane::Plane;
pub use project::{
IssueStatus, IssueStatusCategory, IssueType, Project, ProjectKind, ProjectLifecycle,
};
pub use version::{Version, VersionState};
pub use workspace::{
Checkout, CheckoutRepository, Environment, OwnerKind, RepositoryRole, Workspace,
WorkspaceRepository,
};
+9
View File
@@ -1,7 +1,16 @@
pub mod inbox;
pub mod outbox;
pub mod pool;

pub use inbox::PostgresInbox;
pub use outbox::PostgresOutbox;
pub use pool::{PoolError, connect, migrate};
pub mod inbox;
pub mod outbox;
pub mod pool;
mod working;
mod working_documents;
mod working_environments;
mod working_issues;
mod working_projects;
mod working_rows;
mod working_versions;
mod working_workspaces;

pub use inbox::PostgresInbox;
pub use outbox::PostgresOutbox;
pub use pool::{PoolError, connect, migrate};
pub use working::PostgresCollaboration;
+48
View File
@@ -1,0 +1,48 @@
use async_graphql::{Context, ErrorExtensions, ID};
use syncode_collab_application::ports::{AccessError, StoreError};
use syncode_collab_application::{Collaboration, CollaborationError, Principal};
use uuid::Uuid;

use crate::schema::SessionToken;

pub(crate) async fn principal(ctx: &Context<'_>) -> async_graphql::Result<Principal> {
let app = ctx.data::<std::sync::Arc<Collaboration>>()?;
let token = ctx
.data::<SessionToken>()?
.0
.as_deref()
.ok_or_else(|| error(CollaborationError::Access(AccessError::Unauthenticated)))?;
app.authenticate(token).await.map_err(error)
}

pub(crate) fn app<'a>(ctx: &'a Context<'_>) -> async_graphql::Result<&'a Collaboration> {
Ok(ctx.data::<std::sync::Arc<Collaboration>>()?.as_ref())
}

pub(crate) fn id(value: &ID, field: &str) -> async_graphql::Result<Uuid> {
value
.as_str()
.parse()
.map_err(|_| async_graphql::Error::new(format!("{field} must be a UUID")))
}

pub(crate) fn optional_id(value: Option<&ID>, field: &str) -> async_graphql::Result<Option<Uuid>> {
value.map(|value| id(value, field)).transpose()
}

pub(crate) fn error(error: CollaborationError) -> async_graphql::Error {
let code = match &error {
CollaborationError::Access(AccessError::Unauthenticated) => "UNAUTHENTICATED",
CollaborationError::Access(AccessError::Denied) => "FORBIDDEN",
CollaborationError::Access(AccessError::NotFound)
| CollaborationError::Store(StoreError::NotFound) => "NOT_FOUND",
CollaborationError::Store(StoreError::Conflict(_)) => "CONFLICT",
CollaborationError::Invalid(_) => "BAD_USER_INPUT",
CollaborationError::Access(AccessError::Unavailable(_))
| CollaborationError::Store(StoreError::Unavailable(_)) => "UNAVAILABLE",
CollaborationError::Store(StoreError::Rejected(_)) => "FAILED_PRECONDITION",
};
async_graphql::Error::new(error.to_string()).extend_with(|_, extensions| {
extensions.set("code", code);
})
}
+248
View File
@@ -1,0 +1,248 @@
use async_graphql::{Context, ID, InputObject, Object};
use syncode_collab_application::{
ConfigureEnvironment, CreateDocument, CreateIssue, CreateProject, CreateVersion,
CreateWorkspace,
};
use syncode_collab_model as model;

use crate::context;
use crate::types::{
Document, Environment, Issue, IssuePriority, OwnerKind, Project, ProjectKind, RepositoryRole,
Version, Workspace,
};

#[derive(InputObject)]
struct CreateWorkspaceInput {
owner_kind: OwnerKind,
owner_id: ID,
name: String,
slug: String,
default_environment: String,
default_source_branch: String,
}

#[derive(InputObject)]
struct AttachRepositoryInput {
workspace_id: ID,
owner: String,
name: String,
role: RepositoryRole,
branch: String,
}

#[derive(InputObject)]
struct ConfigureEnvironmentInput {
workspace_id: ID,
name: String,
default_source_branch: String,
infra_branch: Option<String>,
}

#[derive(InputObject)]
struct CreateProjectInput {
workspace_id: ID,
key: String,
name: String,
slug: String,
#[graphql(default)]
description: String,
kind: ProjectKind,
}

#[derive(InputObject)]
struct PutDocumentInput {
workspace_id: ID,
project_id: Option<ID>,
path: String,
content: String,
base_revision_id: Option<ID>,
}

#[derive(InputObject)]
struct CreateIssueInput {
project_id: ID,
title: String,
#[graphql(default)]
description: String,
priority: IssuePriority,
}

#[derive(InputObject)]
struct CreateVersionInput {
project_id: ID,
name: String,
slug: String,
}

pub struct Mutation;

#[Object]
impl Mutation {
async fn create_workspace(
&self,
ctx: &Context<'_>,
input: CreateWorkspaceInput,
) -> async_graphql::Result<Workspace> {
let principal = context::principal(ctx).await?;
context::app(ctx)?
.create_workspace(
principal,
CreateWorkspace {
owner_kind: match input.owner_kind {
OwnerKind::User => model::OwnerKind::User,
OwnerKind::Organization => model::OwnerKind::Organization,
},
owner_id: context::id(&input.owner_id, "owner id")?,
name: input.name,
slug: input.slug,
default_environment: input.default_environment,
default_source_branch: input.default_source_branch,
},
)
.await
.map(Into::into)
.map_err(context::error)
}

async fn attach_repository(
&self,
ctx: &Context<'_>,
input: AttachRepositoryInput,
) -> async_graphql::Result<bool> {
let principal = context::principal(ctx).await?;
context::app(ctx)?
.attach_repository(
principal,
context::id(&input.workspace_id, "workspace id")?,
&input.owner,
&input.name,
match input.role {
RepositoryRole::Source => model::RepositoryRole::Source,
RepositoryRole::Infra => model::RepositoryRole::Infra,
},
&input.branch,
)
.await
.map(|()| true)
.map_err(context::error)
}

async fn configure_environment(
&self,
ctx: &Context<'_>,
input: ConfigureEnvironmentInput,
) -> async_graphql::Result<Environment> {
let principal = context::principal(ctx).await?;
context::app(ctx)?
.configure_environment(
principal,
ConfigureEnvironment {
workspace_id: context::id(&input.workspace_id, "workspace id")?,
name: input.name,
default_source_branch: input.default_source_branch,
infra_branch: input.infra_branch,
},
)
.await
.map(Into::into)
.map_err(context::error)
}

async fn create_project(
&self,
ctx: &Context<'_>,
input: CreateProjectInput,
) -> async_graphql::Result<Project> {
let principal = context::principal(ctx).await?;
context::app(ctx)?
.create_project(
principal,
CreateProject {
workspace_id: context::id(&input.workspace_id, "workspace id")?,
key: input.key,
name: input.name,
slug: input.slug,
description: input.description,
kind: match input.kind {
ProjectKind::Continuous => model::ProjectKind::Continuous,
ProjectKind::Finite => model::ProjectKind::Finite,
},
},
)
.await
.map(Into::into)
.map_err(context::error)
}

async fn put_document(
&self,
ctx: &Context<'_>,
input: PutDocumentInput,
) -> async_graphql::Result<Document> {
let principal = context::principal(ctx).await?;
context::app(ctx)?
.write_document(
principal,
CreateDocument {
workspace_id: context::id(&input.workspace_id, "workspace id")?,
project_id: context::optional_id(input.project_id.as_ref(), "project id")?,
path: input.path,
content: input.content,
base_revision_id: context::optional_id(
input.base_revision_id.as_ref(),
"base revision id",
)?,
},
)
.await
.map(Into::into)
.map_err(context::error)
}

async fn create_issue(
&self,
ctx: &Context<'_>,
input: CreateIssueInput,
) -> async_graphql::Result<Issue> {
let principal = context::principal(ctx).await?;
context::app(ctx)?
.create_issue(
principal,
CreateIssue {
project_id: context::id(&input.project_id, "project id")?,
title: input.title,
description: input.description,
priority: match input.priority {
IssuePriority::None => model::IssuePriority::None,
IssuePriority::Low => model::IssuePriority::Low,
IssuePriority::Normal => model::IssuePriority::Normal,
IssuePriority::High => model::IssuePriority::High,
IssuePriority::Urgent => model::IssuePriority::Urgent,
},
},
)
.await
.map(Into::into)
.map_err(context::error)
}

async fn create_version(
&self,
ctx: &Context<'_>,
input: CreateVersionInput,
) -> async_graphql::Result<Version> {
let principal = context::principal(ctx).await?;
context::app(ctx)?
.create_version(
principal,
CreateVersion {
project_id: context::id(&input.project_id, "project id")?,
name: input.name,
slug: input.slug,
},
)
.await
.map(Into::into)
.map_err(context::error)
}
}
+121
View File
@@ -1,0 +1,121 @@
use async_graphql::{Context, ID, Object};

use crate::context;
use crate::types::{Checkout, Document, Environment, Issue, Project, Version, Workspace};

pub struct Query;

#[Object]
impl Query {
async fn version(&self) -> &'static str {
env!("CARGO_PKG_VERSION")
}

async fn envelope_protocol_version(&self) -> u32 {
syncode_collab_model::envelope::PROTOCOL_VERSION.0
}

async fn workspaces(&self, ctx: &Context<'_>) -> async_graphql::Result<Vec<Workspace>> {
let principal = context::principal(ctx).await?;
context::app(ctx)?
.list_workspaces(principal)
.await
.map(|values| values.into_iter().map(Into::into).collect())
.map_err(context::error)
}

async fn workspace(&self, ctx: &Context<'_>, id: ID) -> async_graphql::Result<Workspace> {
let principal = context::principal(ctx).await?;
context::app(ctx)?
.workspace(principal, context::id(&id, "workspace id")?)
.await
.map(Into::into)
.map_err(context::error)
}

async fn checkout(
&self,
ctx: &Context<'_>,
workspace_id: ID,
environment: Option<String>,
partial: Option<bool>,
) -> async_graphql::Result<Checkout> {
let principal = context::principal(ctx).await?;
context::app(ctx)?
.checkout(
principal,
context::id(&workspace_id, "workspace id")?,
environment.as_deref(),
partial.unwrap_or(false),
)
.await
.map(Into::into)
.map_err(context::error)
}

async fn projects(
&self,
ctx: &Context<'_>,
workspace_id: ID,
) -> async_graphql::Result<Vec<Project>> {
let principal = context::principal(ctx).await?;
context::app(ctx)?
.list_projects(principal, context::id(&workspace_id, "workspace id")?)
.await
.map(|values| values.into_iter().map(Into::into).collect())
.map_err(context::error)
}

async fn environments(
&self,
ctx: &Context<'_>,
workspace_id: ID,
) -> async_graphql::Result<Vec<Environment>> {
let principal = context::principal(ctx).await?;
context::app(ctx)?
.list_environments(principal, context::id(&workspace_id, "workspace id")?)
.await
.map(|values| values.into_iter().map(Into::into).collect())
.map_err(context::error)
}

async fn documents(
&self,
ctx: &Context<'_>,
workspace_id: ID,
project_id: Option<ID>,
) -> async_graphql::Result<Vec<Document>> {
let principal = context::principal(ctx).await?;
context::app(ctx)?
.list_documents(
principal,
context::id(&workspace_id, "workspace id")?,
context::optional_id(project_id.as_ref(), "project id")?,
)
.await
.map(|values| values.into_iter().map(Into::into).collect())
.map_err(context::error)
}

async fn issues(&self, ctx: &Context<'_>, project_id: ID) -> async_graphql::Result<Vec<Issue>> {
let principal = context::principal(ctx).await?;
context::app(ctx)?
.list_issues(principal, context::id(&project_id, "project id")?)
.await
.map(|values| values.into_iter().map(Into::into).collect())
.map_err(context::error)
}

async fn versions(
&self,
ctx: &Context<'_>,
project_id: ID,
) -> async_graphql::Result<Vec<Version>> {
let principal = context::principal(ctx).await?;
context::app(ctx)?
.list_versions(principal, context::id(&project_id, "project id")?)
.await
.map(|values| values.into_iter().map(Into::into).collect())
.map_err(context::error)
}
}
+226
View File
@@ -1,0 +1,226 @@
use async_graphql::{Enum, ID, SimpleObject};
use syncode_collab_model as model;

#[derive(Clone, Copy, Debug, Eq, PartialEq, Enum)]
pub enum OwnerKind {
User,
Organization,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Enum)]
pub enum RepositoryRole {
Source,
Infra,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Enum)]
pub enum ProjectKind {
Continuous,
Finite,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, Enum)]
pub enum IssuePriority {
None,
Low,
Normal,
High,
Urgent,
}

#[derive(Clone, SimpleObject)]
pub struct Workspace {
pub id: ID,
pub owner_kind: OwnerKind,
pub owner_id: ID,
pub name: String,
pub slug: String,
pub default_environment: String,
pub version: i64,
}

#[derive(Clone, SimpleObject)]
pub struct CheckoutRepository {
pub repository_id: ID,
pub owner: String,
pub name: String,
pub role: RepositoryRole,
pub branch: String,
}

#[derive(Clone, SimpleObject)]
pub struct Checkout {
pub workspace: Workspace,
pub environment: String,
pub repositories: Vec<CheckoutRepository>,
pub omitted_repositories: u32,
}

#[derive(Clone, SimpleObject)]
pub struct Environment {
pub name: String,
pub default_source_branch: String,
pub infra_branch: Option<String>,
}

#[derive(Clone, SimpleObject)]
pub struct Project {
pub id: ID,
pub workspace_id: ID,
pub key: String,
pub name: String,
pub slug: String,
pub description: String,
pub kind: ProjectKind,
pub lifecycle: String,
pub version: i64,
}

#[derive(Clone, SimpleObject)]
pub struct Document {
pub id: ID,
pub workspace_id: ID,
pub project_id: Option<ID>,
pub path: String,
pub revision_id: ID,
pub content: String,
}

#[derive(Clone, SimpleObject)]
pub struct Issue {
pub id: ID,
pub project_id: ID,
pub number: i64,
pub title: String,
pub description: String,
pub status_id: ID,
pub type_id: ID,
pub priority: IssuePriority,
pub version: i64,
}

#[derive(Clone, SimpleObject)]
pub struct Version {
pub id: ID,
pub project_id: ID,
pub name: String,
pub slug: String,
pub state: String,
}

impl From<model::Workspace> for Workspace {
fn from(value: model::Workspace) -> Self {
Self {
id: value.id.into(),
owner_kind: match value.owner_kind {
model::OwnerKind::User => OwnerKind::User,
model::OwnerKind::Organization => OwnerKind::Organization,
},
owner_id: value.owner_id.into(),
name: value.name,
slug: value.slug,
default_environment: value.default_environment,
version: value.version,
}
}
}

impl From<model::Checkout> for Checkout {
fn from(value: model::Checkout) -> Self {
Self {
workspace: value.workspace.into(),
environment: value.environment,
repositories: value
.repositories
.into_iter()
.map(|repository| CheckoutRepository {
repository_id: repository.repository_id.into(),
owner: repository.owner,
name: repository.name,
role: match repository.role {
model::RepositoryRole::Source => RepositoryRole::Source,
model::RepositoryRole::Infra => RepositoryRole::Infra,
},
branch: repository.branch,
})
.collect(),
omitted_repositories: value.omitted_repositories,
}
}
}

impl From<model::Environment> for Environment {
fn from(value: model::Environment) -> Self {
Self {
name: value.name,
default_source_branch: value.default_source_branch,
infra_branch: value.infra_branch,
}
}
}

impl From<model::Project> for Project {
fn from(value: model::Project) -> Self {
Self {
id: value.id.into(),
workspace_id: value.workspace_id.into(),
key: value.key,
name: value.name,
slug: value.slug,
description: value.description,
kind: match value.kind {
model::ProjectKind::Continuous => ProjectKind::Continuous,
model::ProjectKind::Finite => ProjectKind::Finite,
},
lifecycle: format!("{:?}", value.lifecycle).to_lowercase(),
version: value.version,
}
}
}

impl From<(model::Document, model::DocumentRevision)> for Document {
fn from(value: (model::Document, model::DocumentRevision)) -> Self {
Self {
id: value.0.id.into(),
workspace_id: value.0.workspace_id.into(),
project_id: value.0.project_id.map(Into::into),
path: value.0.path,
revision_id: value.1.id.into(),
content: value.1.content,
}
}
}

impl From<model::Issue> for Issue {
fn from(value: model::Issue) -> Self {
Self {
id: value.id.into(),
project_id: value.project_id.into(),
number: value.number,
title: value.title,
description: value.description,
status_id: value.status_id.into(),
type_id: value.type_id.into(),
priority: match value.priority {
model::IssuePriority::None => IssuePriority::None,
model::IssuePriority::Low => IssuePriority::Low,
model::IssuePriority::Normal => IssuePriority::Normal,
model::IssuePriority::High => IssuePriority::High,
model::IssuePriority::Urgent => IssuePriority::Urgent,
},
version: value.version,
}
}
}

impl From<model::Version> for Version {
fn from(value: model::Version) -> Self {
Self {
id: value.id.into(),
project_id: value.project_id.into(),
name: value.name,
slug: value.slug,
state: format!("{:?}", value.state).to_lowercase(),
}
}
}
@@ -1,0 +1,62 @@
syntax = "proto3";

package syncode.identity.v1;

service Identity {
rpc ValidateSession(ValidateSessionRequest) returns (ValidateSessionResponse);
rpc CheckCapability(CheckCapabilityRequest) returns (CheckCapabilityResponse);
rpc ResolveRepository(ResolveRepositoryRequest) returns (ResolveRepositoryResponse);
rpc GetRepositoryCoordinates(GetRepositoryCoordinatesRequest) returns (GetRepositoryCoordinatesResponse);
}

enum PrincipalKind {
PRINCIPAL_KIND_UNSPECIFIED = 0;
PRINCIPAL_KIND_USER = 1;
PRINCIPAL_KIND_PLATFORM_AGENT = 2;
PRINCIPAL_KIND_LOCAL_AGENT = 3;
PRINCIPAL_KIND_ACCESS_TOKEN = 4;
}

enum ResourceKind {
RESOURCE_KIND_UNSPECIFIED = 0;
RESOURCE_KIND_ORGANIZATION = 1;
RESOURCE_KIND_REPOSITORY = 2;
RESOURCE_KIND_INSTANCE = 3;
}

message ValidateSessionRequest { string session_token = 1; }

message ValidateSessionResponse {
string principal_id = 1;
PrincipalKind principal_kind = 2;
int64 expires_at_unix = 3;
string owner_user_id = 4;
repeated string capabilities = 5;
string audience = 6;
ResourceKind resource_kind = 7;
string resource_id = 8;
}

message CheckCapabilityRequest {
string principal_id = 1;
PrincipalKind principal_kind = 2;
ResourceKind resource_kind = 3;
string resource_id = 4;
string capability = 5;
}

message CheckCapabilityResponse { bool allowed = 1; }

message ResolveRepositoryRequest {
string owner = 1;
string name = 2;
}

message ResolveRepositoryResponse { string repository_id = 1; }

message GetRepositoryCoordinatesRequest { string repository_id = 1; }

message GetRepositoryCoordinatesResponse {
string owner = 1;
string name = 2;
}
+127
View File
@@ -1,0 +1,127 @@
use tonic::Status;
use uuid::Uuid;

use crate::proto;
use syncode_collab_application::CollaborationError;
use syncode_collab_application::ports::{AccessError, StoreError};
use syncode_collab_model as model;

pub(crate) fn id(value: &str, field: &str) -> Result<Uuid, Status> {
value
.parse()
.map_err(|_| Status::invalid_argument(format!("{field} must be a UUID")))
}

pub(crate) fn optional_id(value: &str, field: &str) -> Result<Option<Uuid>, Status> {
(!value.is_empty()).then(|| id(value, field)).transpose()
}

pub(crate) fn workspace(value: model::Workspace) -> proto::Workspace {
proto::Workspace {
id: value.id.to_string(),
owner_kind: match value.owner_kind {
model::OwnerKind::User => proto::OwnerKind::User.into(),
model::OwnerKind::Organization => proto::OwnerKind::Organization.into(),
},
owner_id: value.owner_id.to_string(),
name: value.name,
slug: value.slug,
default_environment: value.default_environment,
version: value.version,
}
}

pub(crate) fn environment(value: model::Environment) -> proto::Environment {
proto::Environment {
workspace_id: value.workspace_id.to_string(),
name: value.name,
default_source_branch: value.default_source_branch,
infra_branch: value.infra_branch.unwrap_or_default(),
}
}

pub(crate) fn project(value: model::Project) -> proto::Project {
proto::Project {
id: value.id.to_string(),
workspace_id: value.workspace_id.to_string(),
key: value.key,
name: value.name,
slug: value.slug,
description: value.description,
kind: match value.kind {
model::ProjectKind::Continuous => proto::ProjectKind::Continuous.into(),
model::ProjectKind::Finite => proto::ProjectKind::Finite.into(),
},
lifecycle: format!("{:?}", value.lifecycle).to_lowercase(),
default_status_id: value.default_status_id.to_string(),
completion_status_id: value.completion_status_id.to_string(),
version: value.version,
}
}

pub(crate) fn document(value: (model::Document, model::DocumentRevision)) -> proto::Document {
proto::Document {
id: value.0.id.to_string(),
workspace_id: value.0.workspace_id.to_string(),
project_id: value
.0
.project_id
.map(|id| id.to_string())
.unwrap_or_default(),
path: value.0.path,
revision_id: value.1.id.to_string(),
content: value.1.content,
}
}

pub(crate) fn issue(value: model::Issue) -> proto::Issue {
proto::Issue {
id: value.id.to_string(),
project_id: value.project_id.to_string(),
number: value.number,
title: value.title,
description: value.description,
status_id: value.status_id.to_string(),
type_id: value.type_id.to_string(),
priority: match value.priority {
model::IssuePriority::None => proto::IssuePriority::None.into(),
model::IssuePriority::Low => proto::IssuePriority::Low.into(),
model::IssuePriority::Normal => proto::IssuePriority::Normal.into(),
model::IssuePriority::High => proto::IssuePriority::High.into(),
model::IssuePriority::Urgent => proto::IssuePriority::Urgent.into(),
},
version: value.version,
}
}

pub(crate) fn version(value: model::Version) -> proto::Version {
proto::Version {
id: value.id.to_string(),
project_id: value.project_id.to_string(),
name: value.name,
slug: value.slug,
state: format!("{:?}", value.state).to_lowercase(),
}
}

pub(crate) fn error(error: CollaborationError) -> Status {
match error {
CollaborationError::Access(AccessError::Unauthenticated) => {
Status::unauthenticated(error.to_string())
}
CollaborationError::Access(AccessError::Denied) => {
Status::permission_denied(error.to_string())
}
CollaborationError::Access(AccessError::NotFound)
| CollaborationError::Store(StoreError::NotFound) => Status::not_found(error.to_string()),
CollaborationError::Store(StoreError::Conflict(_)) => Status::aborted(error.to_string()),
CollaborationError::Invalid(_) => Status::invalid_argument(error.to_string()),
CollaborationError::Access(AccessError::Unavailable(_))
| CollaborationError::Store(StoreError::Unavailable(_)) => {
Status::unavailable(error.to_string())
}
CollaborationError::Store(StoreError::Rejected(_)) => {
Status::failed_precondition(error.to_string())
}
}
}
@@ -1,0 +1,192 @@
use tonic::Status;

use crate::mapping;
use crate::proto::*;
use syncode_collab_application::{
Collaboration, CreateDocument, CreateIssue, CreateProject, CreateVersion,
};
use syncode_collab_model as model;

pub(crate) async fn create_project(
app: &Collaboration,
request: CreateProjectRequest,
) -> Result<Project, Status> {
let principal = app
.authenticate(&request.session_token)
.await
.map_err(mapping::error)?;
let kind = match ProjectKind::try_from(request.kind) {
Ok(ProjectKind::Continuous) => model::ProjectKind::Continuous,
Ok(ProjectKind::Finite) => model::ProjectKind::Finite,
Ok(ProjectKind::Unspecified) | Err(_) => {
return Err(Status::invalid_argument("kind is required"));
}
};
app.create_project(
principal,
CreateProject {
workspace_id: mapping::id(&request.workspace_id, "workspace_id")?,
key: request.key,
name: request.name,
slug: request.slug,
description: request.description,
kind,
},
)
.await
.map(mapping::project)
.map_err(mapping::error)
}

pub(crate) async fn list_projects(
app: &Collaboration,
request: ListProjectsRequest,
) -> Result<ListProjectsResponse, Status> {
let principal = app
.authenticate(&request.session_token)
.await
.map_err(mapping::error)?;
let projects = app
.list_projects(
principal,
mapping::id(&request.workspace_id, "workspace_id")?,
)
.await
.map_err(mapping::error)?
.into_iter()
.map(mapping::project)
.collect();
Ok(ListProjectsResponse { projects })
}

pub(crate) async fn put_document(
app: &Collaboration,
request: PutDocumentRequest,
) -> Result<Document, Status> {
let principal = app
.authenticate(&request.session_token)
.await
.map_err(mapping::error)?;
app.write_document(
principal,
CreateDocument {
workspace_id: mapping::id(&request.workspace_id, "workspace_id")?,
project_id: mapping::optional_id(&request.project_id, "project_id")?,
path: request.path,
content: request.content,
base_revision_id: mapping::optional_id(&request.base_revision_id, "base_revision_id")?,
},
)
.await
.map(mapping::document)
.map_err(mapping::error)
}

pub(crate) async fn list_documents(
app: &Collaboration,
request: ListDocumentsRequest,
) -> Result<ListDocumentsResponse, Status> {
let principal = app
.authenticate(&request.session_token)
.await
.map_err(mapping::error)?;
let documents = app
.list_documents(
principal,
mapping::id(&request.workspace_id, "workspace_id")?,
mapping::optional_id(&request.project_id, "project_id")?,
)
.await
.map_err(mapping::error)?
.into_iter()
.map(mapping::document)
.collect();
Ok(ListDocumentsResponse { documents })
}

pub(crate) async fn create_issue(
app: &Collaboration,
request: CreateIssueRequest,
) -> Result<Issue, Status> {
let principal = app
.authenticate(&request.session_token)
.await
.map_err(mapping::error)?;
let priority = match IssuePriority::try_from(request.priority) {
Ok(IssuePriority::None | IssuePriority::Unspecified) => model::IssuePriority::None,
Ok(IssuePriority::Low) => model::IssuePriority::Low,
Ok(IssuePriority::Normal) => model::IssuePriority::Normal,
Ok(IssuePriority::High) => model::IssuePriority::High,
Ok(IssuePriority::Urgent) => model::IssuePriority::Urgent,
Err(_) => return Err(Status::invalid_argument("priority is invalid")),
};
app.create_issue(
principal,
CreateIssue {
project_id: mapping::id(&request.project_id, "project_id")?,
title: request.title,
description: request.description,
priority,
},
)
.await
.map(mapping::issue)
.map_err(mapping::error)
}

pub(crate) async fn list_issues(
app: &Collaboration,
request: ListIssuesRequest,
) -> Result<ListIssuesResponse, Status> {
let principal = app
.authenticate(&request.session_token)
.await
.map_err(mapping::error)?;
let issues = app
.list_issues(principal, mapping::id(&request.project_id, "project_id")?)
.await
.map_err(mapping::error)?
.into_iter()
.map(mapping::issue)
.collect();
Ok(ListIssuesResponse { issues })
}

pub(crate) async fn create_version(
app: &Collaboration,
request: CreateVersionRequest,
) -> Result<Version, Status> {
let principal = app
.authenticate(&request.session_token)
.await
.map_err(mapping::error)?;
app.create_version(
principal,
CreateVersion {
project_id: mapping::id(&request.project_id, "project_id")?,
name: request.name,
slug: request.slug,
},
)
.await
.map(mapping::version)
.map_err(mapping::error)
}

pub(crate) async fn list_versions(
app: &Collaboration,
request: ListVersionsRequest,
) -> Result<ListVersionsResponse, Status> {
let principal = app
.authenticate(&request.session_token)
.await
.map_err(mapping::error)?;
let versions = app
.list_versions(principal, mapping::id(&request.project_id, "project_id")?)
.await
.map_err(mapping::error)?
.into_iter()
.map(mapping::version)
.collect();
Ok(ListVersionsResponse { versions })
}
@@ -1,0 +1,11 @@
mod content;
mod workspaces;

pub(crate) use content::{
create_issue, create_project, create_version, list_documents, list_issues, list_projects,
list_versions, put_document,
};
pub(crate) use workspaces::{
attach_repository, configure_environment, create_workspace, get_checkout, get_workspace,
list_environments, list_workspaces,
};
@@ -1,0 +1,188 @@
use tonic::Status;

use crate::mapping;
use crate::proto::*;
use syncode_collab_application::{Collaboration, ConfigureEnvironment, CreateWorkspace};
use syncode_collab_model as model;

pub(crate) async fn create_workspace(
app: &Collaboration,
request: CreateWorkspaceRequest,
) -> Result<Workspace, Status> {
let principal = app
.authenticate(&request.session_token)
.await
.map_err(mapping::error)?;
let owner_kind = match OwnerKind::try_from(request.owner_kind) {
Ok(OwnerKind::User) => model::OwnerKind::User,
Ok(OwnerKind::Organization) => model::OwnerKind::Organization,
Ok(OwnerKind::Unspecified) | Err(_) => {
return Err(Status::invalid_argument("owner_kind is required"));
}
};
let owner_id = if request.owner_id.is_empty() && owner_kind == model::OwnerKind::User {
principal.id
} else {
mapping::id(&request.owner_id, "owner_id")?
};
let workspace = app
.create_workspace(
principal,
CreateWorkspace {
owner_kind,
owner_id,
name: request.name,
slug: request.slug,
default_environment: request.default_environment,
default_source_branch: request.default_source_branch,
},
)
.await
.map_err(mapping::error)?;
Ok(mapping::workspace(workspace))
}

pub(crate) async fn list_workspaces(
app: &Collaboration,
request: ListWorkspacesRequest,
) -> Result<ListWorkspacesResponse, Status> {
let principal = app
.authenticate(&request.session_token)
.await
.map_err(mapping::error)?;
let workspaces = app
.list_workspaces(principal)
.await
.map_err(mapping::error)?
.into_iter()
.map(mapping::workspace)
.collect();
Ok(ListWorkspacesResponse { workspaces })
}

pub(crate) async fn get_workspace(
app: &Collaboration,
request: GetWorkspaceRequest,
) -> Result<Workspace, Status> {
let principal = app
.authenticate(&request.session_token)
.await
.map_err(mapping::error)?;
app.workspace(
principal,
mapping::id(&request.workspace_id, "workspace_id")?,
)
.await
.map(mapping::workspace)
.map_err(mapping::error)
}

pub(crate) async fn attach_repository(
app: &Collaboration,
request: AttachRepositoryRequest,
) -> Result<OperationResponse, Status> {
let principal = app
.authenticate(&request.session_token)
.await
.map_err(mapping::error)?;
let role = match RepositoryRole::try_from(request.role) {
Ok(RepositoryRole::Source) => model::RepositoryRole::Source,
Ok(RepositoryRole::Infra) => model::RepositoryRole::Infra,
Ok(RepositoryRole::Unspecified) | Err(_) => {
return Err(Status::invalid_argument("role is required"));
}
};
app.attach_repository(
principal,
mapping::id(&request.workspace_id, "workspace_id")?,
&request.owner,
&request.name,
role,
&request.branch,
)
.await
.map_err(mapping::error)?;
Ok(OperationResponse {})
}

pub(crate) async fn get_checkout(
app: &Collaboration,
request: GetCheckoutRequest,
) -> Result<Checkout, Status> {
let principal = app
.authenticate(&request.session_token)
.await
.map_err(mapping::error)?;
let environment = (!request.environment.is_empty()).then_some(request.environment.as_str());
let value = app
.checkout(
principal,
mapping::id(&request.workspace_id, "workspace_id")?,
environment,
request.partial,
)
.await
.map_err(mapping::error)?;
Ok(Checkout {
workspace: Some(mapping::workspace(value.workspace)),
environment: value.environment,
repositories: value
.repositories
.into_iter()
.map(|repository| CheckoutRepository {
repository_id: repository.repository_id.to_string(),
owner: repository.owner,
name: repository.name,
role: match repository.role {
model::RepositoryRole::Source => RepositoryRole::Source.into(),
model::RepositoryRole::Infra => RepositoryRole::Infra.into(),
},
branch: repository.branch,
})
.collect(),
omitted_repositories: value.omitted_repositories,
})
}

pub(crate) async fn configure_environment(
app: &Collaboration,
request: ConfigureEnvironmentRequest,
) -> Result<Environment, Status> {
let principal = app
.authenticate(&request.session_token)
.await
.map_err(mapping::error)?;
app.configure_environment(
principal,
ConfigureEnvironment {
workspace_id: mapping::id(&request.workspace_id, "workspace_id")?,
name: request.name,
default_source_branch: request.default_source_branch,
infra_branch: (!request.infra_branch.is_empty()).then_some(request.infra_branch),
},
)
.await
.map(mapping::environment)
.map_err(mapping::error)
}

pub(crate) async fn list_environments(
app: &Collaboration,
request: ListEnvironmentsRequest,
) -> Result<ListEnvironmentsResponse, Status> {
let principal = app
.authenticate(&request.session_token)
.await
.map_err(mapping::error)?;
let environments = app
.list_environments(
principal,
mapping::id(&request.workspace_id, "workspace_id")?,
)
.await
.map_err(mapping::error)?
.into_iter()
.map(mapping::environment)
.collect();
Ok(ListEnvironmentsResponse { environments })
}
@@ -1,0 +1,82 @@
use syncode_collab_model::{Environment, RepositoryRole};
use uuid::Uuid;

use crate::CollaborationError;

pub(crate) fn branch_for(
environment: &Environment,
repository_id: Uuid,
role: RepositoryRole,
) -> Result<String, CollaborationError> {
if let Some((_, branch)) = environment
.branch_overrides
.iter()
.find(|(id, _)| *id == repository_id)
{
return Ok(branch.clone());
}
match role {
RepositoryRole::Source => Ok(environment.default_source_branch.clone()),
RepositoryRole::Infra => environment.infra_branch.clone().ok_or_else(|| {
CollaborationError::Invalid("infra branch is not configured".to_owned())
}),
}
}

pub(crate) fn validate_name(value: &str, field: &str) -> Result<(), CollaborationError> {
if value.trim().is_empty() || value.len() > 200 {
return Err(CollaborationError::Invalid(format!(
"{field} must contain 1 to 200 characters"
)));
}
Ok(())
}

pub(crate) fn validate_slug(value: &str) -> Result<(), CollaborationError> {
if value.is_empty()
|| value.len() > 80
|| !value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
{
return Err(CollaborationError::Invalid(
"slug must contain only lowercase letters, digits, and hyphens".to_owned(),
));
}
Ok(())
}

pub(crate) fn validate_project_key(value: &str) -> Result<(), CollaborationError> {
if value.len() < 2
|| value.len() > 10
|| !value
.bytes()
.all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit())
{
return Err(CollaborationError::Invalid(
"project key must contain 2 to 10 uppercase letters or digits".to_owned(),
));
}
Ok(())
}

pub(crate) fn validate_branch(value: &str) -> Result<(), CollaborationError> {
validate_name(value, "branch")?;
if value.starts_with('-') || value.contains("..") || value.contains([' ', '~', '^', ':']) {
return Err(CollaborationError::Invalid("branch is invalid".to_owned()));
}
Ok(())
}

pub(crate) fn validate_document_path(value: &str) -> Result<(), CollaborationError> {
if value.is_empty()
|| value.starts_with('/')
|| value.split('/').any(|part| part.is_empty() || part == "..")
|| !value.ends_with(".md")
{
return Err(CollaborationError::Invalid(
"document path must be a relative Markdown path".to_owned(),
));
}
Ok(())
}
@@ -1,0 +1,87 @@
use syncode_collab_model::{IssuePriority, OwnerKind, ProjectKind};
use uuid::Uuid;

use crate::ports::{AccessError, StoreError};

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PrincipalKind {
User,
PlatformAgent,
LocalAgent,
AccessToken,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct Principal {
pub id: Uuid,
pub kind: PrincipalKind,
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RepositoryCoordinates {
pub id: Uuid,
pub owner: String,
pub name: String,
}

#[derive(Clone, Debug)]
pub struct CreateWorkspace {
pub owner_kind: OwnerKind,
pub owner_id: Uuid,
pub name: String,
pub slug: String,
pub default_environment: String,
pub default_source_branch: String,
}

#[derive(Clone, Debug)]
pub struct CreateProject {
pub workspace_id: Uuid,
pub key: String,
pub name: String,
pub slug: String,
pub description: String,
pub kind: ProjectKind,
}

#[derive(Clone, Debug)]
pub struct CreateDocument {
pub workspace_id: Uuid,
pub project_id: Option<Uuid>,
pub path: String,
pub content: String,
pub base_revision_id: Option<Uuid>,
}

#[derive(Clone, Debug)]
pub struct CreateIssue {
pub project_id: Uuid,
pub title: String,
pub description: String,
pub priority: IssuePriority,
}

#[derive(Clone, Debug)]
pub struct CreateVersion {
pub project_id: Uuid,
pub name: String,
pub slug: String,
}

#[derive(Clone, Debug)]
pub struct ConfigureEnvironment {
pub workspace_id: Uuid,
pub name: String,
pub default_source_branch: String,
pub infra_branch: Option<String>,
}

#[derive(Debug, thiserror::Error)]
pub enum CollaborationError {
#[error(transparent)]
Access(#[from] AccessError),
#[error(transparent)]
Store(#[from] StoreError),
#[error("invalid collaboration input: {0}")]
Invalid(String),
}
@@ -1,0 +1,25 @@
use syncode_collab_model::Version;
use uuid::Uuid;

use crate::validation::{validate_name, validate_slug};
use crate::{Collaboration, CollaborationError, CreateVersion, Principal};

impl Collaboration {
pub async fn create_version(
&self,
principal: Principal,
command: CreateVersion,
) -> Result<Version, CollaborationError> {
validate_name(&command.name, "version name")?;
validate_slug(&command.slug)?;
Ok(self.store.create_version(principal.id, &command).await?)
}

pub async fn list_versions(
&self,
principal: Principal,
project_id: Uuid,
) -> Result<Vec<Version>, CollaborationError> {
Ok(self.store.list_versions(principal.id, project_id).await?)
}
}
@@ -1,0 +1,239 @@
use std::sync::Arc;

use syncode_collab_model::{
Checkout, CheckoutRepository, Document, DocumentRevision, Issue, OwnerKind, Project,
RepositoryRole, Workspace,
};
use uuid::Uuid;

use crate::ports::{AccessError, CollaborationStore, IdentityAccess};
use crate::validation::{
branch_for, validate_branch, validate_document_path, validate_name, validate_project_key,
validate_slug,
};
use crate::{
CollaborationError, ConfigureEnvironment, CreateDocument, CreateIssue, CreateProject,
CreateWorkspace, Principal,
};

#[derive(Clone)]
pub struct Collaboration {
pub(crate) store: Arc<dyn CollaborationStore>,
identity: Arc<dyn IdentityAccess>,
}

impl Collaboration {
pub fn new(store: Arc<dyn CollaborationStore>, identity: Arc<dyn IdentityAccess>) -> Self {
Self { store, identity }
}

pub async fn authenticate(&self, token: &str) -> Result<Principal, CollaborationError> {
if token.is_empty() {
return Err(CollaborationError::Access(AccessError::Unauthenticated));
}
Ok(self.identity.authenticate(token).await?)
}

pub async fn create_workspace(
&self,
principal: Principal,
command: CreateWorkspace,
) -> Result<Workspace, CollaborationError> {
validate_name(&command.name, "workspace name")?;
validate_slug(&command.slug)?;
validate_name(&command.default_environment, "environment name")?;
validate_branch(&command.default_source_branch)?;
match command.owner_kind {
OwnerKind::User if command.owner_id != principal.id => {
return Err(CollaborationError::Access(AccessError::Denied));
}
OwnerKind::Organization => {
self.identity
.require_organization_admin(principal, command.owner_id)
.await?;
}
OwnerKind::User => {}
}
Ok(self.store.create_workspace(principal.id, &command).await?)
}

pub async fn list_workspaces(
&self,
principal: Principal,
) -> Result<Vec<Workspace>, CollaborationError> {
Ok(self.store.list_workspaces(principal.id).await?)
}

pub async fn workspace(
&self,
principal: Principal,
workspace_id: Uuid,
) -> Result<Workspace, CollaborationError> {
Ok(self.store.workspace(principal.id, workspace_id).await?)
}

pub async fn attach_repository(
&self,
principal: Principal,
workspace_id: Uuid,
owner: &str,
name: &str,
role: RepositoryRole,
branch: &str,
) -> Result<(), CollaborationError> {
validate_branch(branch)?;
self.store.workspace(principal.id, workspace_id).await?;
let repository = self
.identity
.resolve_readable_repository(principal, owner, name)
.await?;
for stored in self.store.repositories(principal.id, workspace_id).await? {
let existing = self
.identity
.repository_coordinates(principal, stored.repository_id)
.await?;
if role == RepositoryRole::Source
&& stored.role == RepositoryRole::Source
&& existing.name == repository.name
&& existing.id != repository.id
{
return Err(CollaborationError::Invalid(format!(
"workspace already contains a source path named {}",
repository.name
)));
}
}
self.store
.attach_repository(principal.id, workspace_id, repository.id, role, branch)
.await?;
Ok(())
}

pub async fn checkout(
&self,
principal: Principal,
workspace_id: Uuid,
environment: Option<&str>,
partial: bool,
) -> Result<Checkout, CollaborationError> {
let workspace = self.store.workspace(principal.id, workspace_id).await?;
let environment_name = environment.unwrap_or(&workspace.default_environment);
let environment = self
.store
.environment(principal.id, workspace_id, environment_name)
.await?;
let repositories = self.store.repositories(principal.id, workspace_id).await?;
let mut checkout = Vec::new();
let mut omitted = 0_u32;
for stored in repositories {
match self
.identity
.repository_coordinates(principal, stored.repository_id)
.await
{
Ok(repository) => checkout.push(CheckoutRepository {
repository_id: repository.id,
owner: repository.owner,
name: repository.name,
role: stored.role,
branch: branch_for(&environment, stored.repository_id, stored.role)?,
}),
Err(AccessError::Denied | AccessError::NotFound) if partial => omitted += 1,
Err(error) => return Err(error.into()),
}
}
Ok(Checkout {
workspace,
environment: environment.name,
repositories: checkout,
omitted_repositories: omitted,
})
}

pub async fn configure_environment(
&self,
principal: Principal,
command: ConfigureEnvironment,
) -> Result<syncode_collab_model::Environment, CollaborationError> {
validate_name(&command.name, "environment name")?;
validate_branch(&command.default_source_branch)?;
if let Some(branch) = &command.infra_branch {
validate_branch(branch)?;
}
Ok(self
.store
.configure_environment(principal.id, &command)
.await?)
}

pub async fn list_environments(
&self,
principal: Principal,
workspace_id: Uuid,
) -> Result<Vec<syncode_collab_model::Environment>, CollaborationError> {
Ok(self
.store
.list_environments(principal.id, workspace_id)
.await?)
}

pub async fn create_project(
&self,
principal: Principal,
command: CreateProject,
) -> Result<Project, CollaborationError> {
self.store
.workspace(principal.id, command.workspace_id)
.await?;
validate_project_key(&command.key)?;
validate_name(&command.name, "project name")?;
validate_slug(&command.slug)?;
Ok(self.store.create_project(principal.id, &command).await?)
}

pub async fn list_projects(
&self,
principal: Principal,
workspace_id: Uuid,
) -> Result<Vec<Project>, CollaborationError> {
Ok(self.store.list_projects(principal.id, workspace_id).await?)
}

pub async fn write_document(
&self,
principal: Principal,
command: CreateDocument,
) -> Result<(Document, DocumentRevision), CollaborationError> {
validate_document_path(&command.path)?;
Ok(self.store.write_document(principal.id, &command).await?)
}

pub async fn list_documents(
&self,
principal: Principal,
workspace_id: Uuid,
project_id: Option<Uuid>,
) -> Result<Vec<(Document, DocumentRevision)>, CollaborationError> {
Ok(self
.store
.list_documents(principal.id, workspace_id, project_id)
.await?)
}

pub async fn create_issue(
&self,
principal: Principal,
command: CreateIssue,
) -> Result<Issue, CollaborationError> {
validate_name(&command.title, "issue title")?;
Ok(self.store.create_issue(principal.id, &command).await?)
}

pub async fn list_issues(
&self,
principal: Principal,
project_id: Uuid,
) -> Result<Vec<Issue>, CollaborationError> {
Ok(self.store.list_issues(principal.id, project_id).await?)
}
}
+29
View File
@@ -1,0 +1,29 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;

#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DocumentScope {
Workspace,
Project,
}

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct Document {
pub id: Uuid,
pub workspace_id: Uuid,
pub project_id: Option<Uuid>,
pub path: String,
pub current_revision_id: Uuid,
pub updated_at: DateTime<Utc>,
}

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct DocumentRevision {
pub id: Uuid,
pub document_id: Uuid,
pub previous_revision_id: Option<Uuid>,
pub author_principal_id: Uuid,
pub content: String,
pub created_at: DateTime<Utc>,
}
+30
View File
@@ -1,0 +1,30 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;

#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IssuePriority {
None,
Low,
Normal,
High,
Urgent,
}

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct Issue {
pub id: Uuid,
pub project_id: Uuid,
pub number: i64,
pub title: String,
pub description: String,
pub type_id: Uuid,
pub status_id: Uuid,
pub priority: IssuePriority,
pub assignee_principal_id: Option<Uuid>,
pub parent_issue_id: Option<Uuid>,
pub author_principal_id: Uuid,
pub version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
+61
View File
@@ -1,0 +1,61 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;

#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectKind {
Continuous,
Finite,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum ProjectLifecycle {
Planned,
Active,
Completed,
Canceled,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum IssueStatusCategory {
Backlog,
Unstarted,
Started,
Completed,
Canceled,
}

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct Project {
pub id: Uuid,
pub workspace_id: Uuid,
pub key: String,
pub name: String,
pub slug: String,
pub description: String,
pub kind: ProjectKind,
pub lifecycle: ProjectLifecycle,
pub default_status_id: Uuid,
pub completion_status_id: Uuid,
pub version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct IssueStatus {
pub id: Uuid,
pub project_id: Uuid,
pub name: String,
pub category: IssueStatusCategory,
pub position: i32,
}

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct IssueType {
pub id: Uuid,
pub project_id: Uuid,
pub name: String,
}
+20
View File
@@ -1,0 +1,20 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;

#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum VersionState {
Planned,
Released,
}

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct Version {
pub id: Uuid,
pub project_id: Uuid,
pub name: String,
pub slug: String,
pub state: VersionState,
pub created_at: DateTime<Utc>,
pub released_at: Option<DateTime<Utc>>,
}
+64
View File
@@ -1,0 +1,64 @@
use chrono::{DateTime, Utc};
use uuid::Uuid;

#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum OwnerKind {
User,
Organization,
}

#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RepositoryRole {
Source,
Infra,
}

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct Workspace {
pub id: Uuid,
pub owner_kind: OwnerKind,
pub owner_id: Uuid,
pub name: String,
pub slug: String,
pub default_environment: String,
pub version: i64,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct WorkspaceRepository {
pub repository_id: Uuid,
pub owner: String,
pub name: String,
pub role: RepositoryRole,
}

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct Environment {
pub id: Uuid,
pub workspace_id: Uuid,
pub name: String,
pub default_source_branch: String,
pub infra_branch: Option<String>,
pub branch_overrides: Vec<(Uuid, String)>,
}

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct CheckoutRepository {
pub repository_id: Uuid,
pub owner: String,
pub name: String,
pub role: RepositoryRole,
pub branch: String,
}

#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
pub struct Checkout {
pub workspace: Workspace,
pub environment: String,
pub repositories: Vec<CheckoutRepository>,
pub omitted_repositories: u32,
}
@@ -1,0 +1,144 @@
CREATE TABLE workspace (
id uuid PRIMARY KEY,
owner_kind text NOT NULL CHECK (owner_kind IN ('user', 'organization')),
owner_id uuid NOT NULL,
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 200),
slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,79}$'),
default_environment text NOT NULL,
version bigint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (owner_kind, owner_id, slug)
);

CREATE TABLE workspace_access (
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
principal_id uuid NOT NULL,
capabilities text[] NOT NULL,
PRIMARY KEY (workspace_id, principal_id)
);

CREATE TABLE workspace_repository (
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
repository_id uuid NOT NULL,
role text NOT NULL CHECK (role IN ('source', 'infra')),
PRIMARY KEY (workspace_id, repository_id)
);

CREATE UNIQUE INDEX workspace_one_infra
ON workspace_repository (workspace_id) WHERE role = 'infra';

CREATE TABLE workspace_environment (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
name text NOT NULL,
default_source_branch text NOT NULL,
infra_branch text,
UNIQUE (workspace_id, name)
);

CREATE TABLE environment_branch_override (
environment_id uuid NOT NULL REFERENCES workspace_environment(id) ON DELETE CASCADE,
repository_id uuid NOT NULL,
branch text NOT NULL,
PRIMARY KEY (environment_id, repository_id)
);

CREATE TABLE project (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
key text NOT NULL CHECK (key ~ '^[A-Z0-9]{2,10}$'),
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 200),
slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,79}$'),
description text NOT NULL DEFAULT '',
kind text NOT NULL CHECK (kind IN ('continuous', 'finite')),
lifecycle text NOT NULL DEFAULT 'active'
CHECK (lifecycle IN ('planned', 'active', 'completed', 'canceled')),
default_status_id uuid NOT NULL,
completion_status_id uuid NOT NULL,
next_issue_number bigint NOT NULL DEFAULT 1,
version bigint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (workspace_id, key),
UNIQUE (workspace_id, slug)
);

CREATE TABLE issue_status (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
name text NOT NULL,
category text NOT NULL CHECK (
category IN ('backlog', 'unstarted', 'started', 'completed', 'canceled')
),
position integer NOT NULL,
UNIQUE (project_id, name),
UNIQUE (project_id, position)
);

CREATE TABLE issue_type (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
name text NOT NULL,
UNIQUE (project_id, name)
);

ALTER TABLE project
ADD CONSTRAINT project_default_status_fk
FOREIGN KEY (default_status_id) REFERENCES issue_status(id) DEFERRABLE INITIALLY DEFERRED,
ADD CONSTRAINT project_completion_status_fk
FOREIGN KEY (completion_status_id) REFERENCES issue_status(id) DEFERRABLE INITIALLY DEFERRED;

CREATE TABLE document (
id uuid PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES workspace(id) ON DELETE CASCADE,
project_id uuid REFERENCES project(id) ON DELETE CASCADE,
path text NOT NULL,
current_revision_id uuid NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);

CREATE UNIQUE INDEX workspace_document_path
ON document (workspace_id, path) WHERE project_id IS NULL;

CREATE UNIQUE INDEX project_document_path
ON document (project_id, path) WHERE project_id IS NOT NULL;

CREATE TABLE document_revision (
id uuid PRIMARY KEY,
document_id uuid NOT NULL REFERENCES document(id) ON DELETE CASCADE,
previous_revision_id uuid REFERENCES document_revision(id),
author_principal_id uuid NOT NULL,
content text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);

ALTER TABLE document
ADD CONSTRAINT document_current_revision_fk
FOREIGN KEY (current_revision_id) REFERENCES document_revision(id)
DEFERRABLE INITIALLY DEFERRED;

CREATE TABLE issue (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
number bigint NOT NULL,
title text NOT NULL CHECK (length(title) BETWEEN 1 AND 200),
description text NOT NULL DEFAULT '',
type_id uuid NOT NULL REFERENCES issue_type(id),
status_id uuid NOT NULL REFERENCES issue_status(id),
priority text NOT NULL DEFAULT 'none'
CHECK (priority IN ('none', 'low', 'normal', 'high', 'urgent')),
assignee_principal_id uuid,
parent_issue_id uuid REFERENCES issue(id),
author_principal_id uuid NOT NULL,
version bigint NOT NULL DEFAULT 1,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (project_id, number),
CHECK (parent_issue_id IS NULL OR parent_issue_id <> id)
);

CREATE INDEX workspace_access_principal ON workspace_access (principal_id, workspace_id);
CREATE INDEX project_workspace ON project (workspace_id);
CREATE INDEX issue_project ON issue (project_id, number);
CREATE INDEX document_workspace ON document (workspace_id, project_id, path);
@@ -1,0 +1,13 @@
CREATE TABLE project_version (
id uuid PRIMARY KEY,
project_id uuid NOT NULL REFERENCES project(id) ON DELETE CASCADE,
name text NOT NULL CHECK (length(name) BETWEEN 1 AND 200),
slug text NOT NULL CHECK (slug ~ '^[a-z0-9][a-z0-9-]{0,79}$'),
state text NOT NULL DEFAULT 'planned' CHECK (state IN ('planned', 'released')),
created_at timestamptz NOT NULL DEFAULT now(),
released_at timestamptz,
UNIQUE (project_id, slug),
CHECK ((state = 'released') = (released_at IS NOT NULL))
);

CREATE INDEX project_version_project ON project_version (project_id, created_at, id);
+187
View File
@@ -1,0 +1,187 @@
use async_trait::async_trait;
use sqlx::PgPool;
use syncode_collab_application::StoreError;
use syncode_collab_application::ports::{CollaborationStore, StoredRepository};
use syncode_collab_application::{CreateDocument, CreateIssue, CreateProject, CreateWorkspace};
use syncode_collab_model::{
Document, DocumentRevision, Environment, Issue, Project, RepositoryRole, Version, Workspace,
};
use uuid::Uuid;

pub struct PostgresCollaboration {
pub(crate) pool: PgPool,
}

impl PostgresCollaboration {
pub fn new(pool: PgPool) -> Self {
Self { pool }
}
}

#[async_trait]
impl CollaborationStore for PostgresCollaboration {
async fn create_workspace(
&self,
principal_id: Uuid,
command: &CreateWorkspace,
) -> Result<Workspace, StoreError> {
crate::working_workspaces::create(&self.pool, principal_id, command).await
}

async fn list_workspaces(&self, principal_id: Uuid) -> Result<Vec<Workspace>, StoreError> {
crate::working_workspaces::list(&self.pool, principal_id).await
}

async fn workspace(
&self,
principal_id: Uuid,
workspace_id: Uuid,
) -> Result<Workspace, StoreError> {
crate::working_workspaces::get(&self.pool, principal_id, workspace_id).await
}

async fn attach_repository(
&self,
principal_id: Uuid,
workspace_id: Uuid,
repository_id: Uuid,
role: RepositoryRole,
branch: &str,
) -> Result<(), StoreError> {
crate::working_workspaces::attach_repository(
&self.pool,
principal_id,
workspace_id,
repository_id,
role,
branch,
)
.await
}

async fn repositories(
&self,
principal_id: Uuid,
workspace_id: Uuid,
) -> Result<Vec<StoredRepository>, StoreError> {
crate::working_workspaces::repositories(&self.pool, principal_id, workspace_id).await
}

async fn environment(
&self,
principal_id: Uuid,
workspace_id: Uuid,
name: &str,
) -> Result<Environment, StoreError> {
crate::working_environments::get(&self.pool, principal_id, workspace_id, name).await
}

async fn configure_environment(
&self,
principal_id: Uuid,
command: &syncode_collab_application::ConfigureEnvironment,
) -> Result<Environment, StoreError> {
crate::working_environments::configure(&self.pool, principal_id, command).await
}

async fn list_environments(
&self,
principal_id: Uuid,
workspace_id: Uuid,
) -> Result<Vec<Environment>, StoreError> {
crate::working_environments::list(&self.pool, principal_id, workspace_id).await
}

async fn create_project(
&self,
principal_id: Uuid,
command: &CreateProject,
) -> Result<Project, StoreError> {
crate::working_projects::create(&self.pool, principal_id, command).await
}

async fn list_projects(
&self,
principal_id: Uuid,
workspace_id: Uuid,
) -> Result<Vec<Project>, StoreError> {
crate::working_projects::list(&self.pool, principal_id, workspace_id).await
}

async fn write_document(
&self,
principal_id: Uuid,
command: &CreateDocument,
) -> Result<(Document, DocumentRevision), StoreError> {
crate::working_documents::write(&self.pool, principal_id, command).await
}

async fn list_documents(
&self,
principal_id: Uuid,
workspace_id: Uuid,
project_id: Option<Uuid>,
) -> Result<Vec<(Document, DocumentRevision)>, StoreError> {
crate::working_documents::list(&self.pool, principal_id, workspace_id, project_id).await
}

async fn create_issue(
&self,
principal_id: Uuid,
command: &CreateIssue,
) -> Result<Issue, StoreError> {
crate::working_issues::create(&self.pool, principal_id, command).await
}

async fn list_issues(
&self,
principal_id: Uuid,
project_id: Uuid,
) -> Result<Vec<Issue>, StoreError> {
crate::working_issues::list(&self.pool, principal_id, project_id).await
}

async fn create_version(
&self,
principal_id: Uuid,
command: &syncode_collab_application::CreateVersion,
) -> Result<Version, StoreError> {
crate::working_versions::create(&self.pool, principal_id, command).await
}

async fn list_versions(
&self,
principal_id: Uuid,
project_id: Uuid,
) -> Result<Vec<Version>, StoreError> {
crate::working_versions::list(&self.pool, principal_id, project_id).await
}
}

pub(crate) fn unavailable(error: sqlx::Error) -> StoreError {
StoreError::Unavailable(error.to_string())
}

pub(crate) fn rejected(error: sqlx::Error) -> StoreError {
StoreError::Rejected(error.to_string())
}

pub(crate) async fn outbox(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
message_type: &str,
resource_id: Uuid,
payload: serde_json::Value,
) -> Result<(), StoreError> {
sqlx::query(
"INSERT INTO event_outbox (message_id, message_type, resource_id, payload) \
VALUES ($1, $2, $3, $4)",
)
.bind(Uuid::new_v4())
.bind(message_type)
.bind(resource_id)
.bind(payload)
.execute(&mut **transaction)
.await
.map_err(rejected)?;
Ok(())
}
@@ -1,0 +1,205 @@
use sqlx::{FromRow, PgPool};
use syncode_collab_application::{CreateDocument, StoreError};
use syncode_collab_model::{Document, DocumentRevision};
use uuid::Uuid;

use crate::working::{outbox, rejected, unavailable};

#[derive(FromRow)]
struct DocumentRow {
id: Uuid,
workspace_id: Uuid,
project_id: Option<Uuid>,
path: String,
current_revision_id: Uuid,
updated_at: chrono::DateTime<chrono::Utc>,
revision_id: Uuid,
previous_revision_id: Option<Uuid>,
author_principal_id: Uuid,
content: String,
revision_created_at: chrono::DateTime<chrono::Utc>,
}

impl DocumentRow {
fn split(self) -> (Document, DocumentRevision) {
(
Document {
id: self.id,
workspace_id: self.workspace_id,
project_id: self.project_id,
path: self.path,
current_revision_id: self.current_revision_id,
updated_at: self.updated_at,
},
DocumentRevision {
id: self.revision_id,
document_id: self.id,
previous_revision_id: self.previous_revision_id,
author_principal_id: self.author_principal_id,
content: self.content,
created_at: self.revision_created_at,
},
)
}
}

const COLUMNS: &str = "d.id, d.workspace_id, d.project_id, d.path, \
d.current_revision_id, d.updated_at, r.id AS revision_id, \
r.previous_revision_id, r.author_principal_id, r.content, \
r.created_at AS revision_created_at";

pub async fn write(
pool: &PgPool,
principal_id: Uuid,
command: &CreateDocument,
) -> Result<(Document, DocumentRevision), StoreError> {
let mut transaction = pool.begin().await.map_err(unavailable)?;
require_scope(
&mut transaction,
principal_id,
command.workspace_id,
command.project_id,
)
.await?;
let existing: Option<(Uuid, Uuid)> = sqlx::query_as(
"SELECT id, current_revision_id FROM document WHERE workspace_id = $1 \
AND project_id IS NOT DISTINCT FROM $2 AND path = $3 FOR UPDATE",
)
.bind(command.workspace_id)
.bind(command.project_id)
.bind(&command.path)
.fetch_optional(&mut *transaction)
.await
.map_err(unavailable)?;
let (document_id, previous_revision_id) = match existing {
Some((document_id, current)) => {
if command.base_revision_id != Some(current) {
return Err(StoreError::Conflict(
"document base revision is stale".to_owned(),
));
}
(document_id, Some(current))
}
None if command.base_revision_id.is_some() => {
return Err(StoreError::Conflict(
"new document cannot have a base revision".to_owned(),
));
}
None => (Uuid::new_v4(), None),
};
let revision_id = Uuid::new_v4();
if existing.is_none() {
sqlx::query(
"INSERT INTO document \
(id, workspace_id, project_id, path, current_revision_id) \
VALUES ($1, $2, $3, $4, $5)",
)
.bind(document_id)
.bind(command.workspace_id)
.bind(command.project_id)
.bind(&command.path)
.bind(revision_id)
.execute(&mut *transaction)
.await
.map_err(rejected)?;
}
sqlx::query(
"INSERT INTO document_revision \
(id, document_id, previous_revision_id, author_principal_id, content) \
VALUES ($1, $2, $3, $4, $5)",
)
.bind(revision_id)
.bind(document_id)
.bind(previous_revision_id)
.bind(principal_id)
.bind(&command.content)
.execute(&mut *transaction)
.await
.map_err(rejected)?;
if existing.is_some() {
sqlx::query(
"UPDATE document SET current_revision_id = $2, updated_at = now() WHERE id = $1",
)
.bind(document_id)
.bind(revision_id)
.execute(&mut *transaction)
.await
.map_err(rejected)?;
}
outbox(
&mut transaction,
"collaboration.document.revised",
document_id,
serde_json::json!({"document_id": document_id, "revision_id": revision_id, "actor_id": principal_id}),
)
.await?;
transaction.commit().await.map_err(unavailable)?;
get(pool, principal_id, document_id).await
}

pub async fn list(
pool: &PgPool,
principal_id: Uuid,
workspace_id: Uuid,
project_id: Option<Uuid>,
) -> Result<Vec<(Document, DocumentRevision)>, StoreError> {
let query = format!(
"SELECT {COLUMNS} FROM document d \
JOIN document_revision r ON r.id = d.current_revision_id \
JOIN workspace_access a ON a.workspace_id = d.workspace_id \
WHERE d.workspace_id = $1 AND d.project_id IS NOT DISTINCT FROM $2 \
AND a.principal_id = $3 AND 'read' = ANY(a.capabilities) ORDER BY d.path"
);
Ok(sqlx::query_as::<_, DocumentRow>(&query)
.bind(workspace_id)
.bind(project_id)
.bind(principal_id)
.fetch_all(pool)
.await
.map_err(unavailable)?
.into_iter()
.map(DocumentRow::split)
.collect())
}

async fn get(
pool: &PgPool,
principal_id: Uuid,
document_id: Uuid,
) -> Result<(Document, DocumentRevision), StoreError> {
let query = format!(
"SELECT {COLUMNS} FROM document d \
JOIN document_revision r ON r.id = d.current_revision_id \
JOIN workspace_access a ON a.workspace_id = d.workspace_id \
WHERE d.id = $1 AND a.principal_id = $2 AND 'read' = ANY(a.capabilities)"
);
sqlx::query_as::<_, DocumentRow>(&query)
.bind(document_id)
.bind(principal_id)
.fetch_optional(pool)
.await
.map_err(unavailable)?
.map(DocumentRow::split)
.ok_or(StoreError::NotFound)
}

async fn require_scope(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
principal_id: Uuid,
workspace_id: Uuid,
project_id: Option<Uuid>,
) -> Result<(), StoreError> {
let allowed: Option<(bool,)> = sqlx::query_as(
"SELECT true FROM workspace_access a WHERE a.workspace_id = $1 \
AND a.principal_id = $2 AND 'write' = ANY(a.capabilities) \
AND ($3::uuid IS NULL OR EXISTS (SELECT 1 FROM project p \
WHERE p.id = $3 AND p.workspace_id = a.workspace_id))",
)
.bind(workspace_id)
.bind(principal_id)
.bind(project_id)
.fetch_optional(&mut **transaction)
.await
.map_err(unavailable)?;
allowed.map(|_| ()).ok_or(StoreError::NotFound)
}
@@ -1,0 +1,95 @@
use sqlx::PgPool;
use syncode_collab_application::{ConfigureEnvironment, StoreError};
use syncode_collab_model::Environment;
use uuid::Uuid;

use crate::working::{outbox, rejected, unavailable};

pub async fn get(
pool: &PgPool,
principal_id: Uuid,
workspace_id: Uuid,
name: &str,
) -> Result<Environment, StoreError> {
crate::working_workspaces::get(pool, principal_id, workspace_id).await?;
let row: Option<(Uuid, String, String, Option<String>)> = sqlx::query_as(
"SELECT id, name, default_source_branch, infra_branch \
FROM workspace_environment WHERE workspace_id = $1 AND name = $2",
)
.bind(workspace_id)
.bind(name)
.fetch_optional(pool)
.await
.map_err(unavailable)?;
let (id, name, default_source_branch, infra_branch) = row.ok_or(StoreError::NotFound)?;
let branch_overrides = sqlx::query_as(
"SELECT repository_id, branch FROM environment_branch_override \
WHERE environment_id = $1 ORDER BY repository_id",
)
.bind(id)
.fetch_all(pool)
.await
.map_err(unavailable)?;
Ok(Environment {
id,
workspace_id,
name,
default_source_branch,
infra_branch,
branch_overrides,
})
}

pub async fn configure(
pool: &PgPool,
principal_id: Uuid,
command: &ConfigureEnvironment,
) -> Result<Environment, StoreError> {
let mut transaction = pool.begin().await.map_err(unavailable)?;
crate::working_workspaces::require_write(&mut transaction, principal_id, command.workspace_id)
.await?;
sqlx::query(
"INSERT INTO workspace_environment \
(id, workspace_id, name, default_source_branch, infra_branch) \
VALUES ($1,$2,$3,$4,$5) ON CONFLICT (workspace_id, name) DO UPDATE SET \
default_source_branch = EXCLUDED.default_source_branch, \
infra_branch = EXCLUDED.infra_branch",
)
.bind(Uuid::new_v4())
.bind(command.workspace_id)
.bind(&command.name)
.bind(&command.default_source_branch)
.bind(&command.infra_branch)
.execute(&mut *transaction)
.await
.map_err(rejected)?;
outbox(
&mut transaction,
"collaboration.workspace.environment_configured",
command.workspace_id,
serde_json::json!({"workspace_id": command.workspace_id, "environment": command.name, "actor_id": principal_id}),
)
.await?;
transaction.commit().await.map_err(unavailable)?;
get(pool, principal_id, command.workspace_id, &command.name).await
}

pub async fn list(
pool: &PgPool,
principal_id: Uuid,
workspace_id: Uuid,
) -> Result<Vec<Environment>, StoreError> {
crate::working_workspaces::get(pool, principal_id, workspace_id).await?;
let names: Vec<(String,)> = sqlx::query_as(
"SELECT name FROM workspace_environment WHERE workspace_id = $1 ORDER BY name",
)
.bind(workspace_id)
.fetch_all(pool)
.await
.map_err(unavailable)?;
let mut values = Vec::with_capacity(names.len());
for (name,) in names {
values.push(get(pool, principal_id, workspace_id, &name).await?);
}
Ok(values)
}
+143
View File
@@ -1,0 +1,143 @@
use sqlx::{FromRow, PgPool};
use syncode_collab_application::{CreateIssue, StoreError};
use syncode_collab_model::{Issue, IssuePriority};
use uuid::Uuid;

use crate::working::{outbox, rejected, unavailable};

#[derive(FromRow)]
struct IssueRow {
id: Uuid,
project_id: Uuid,
number: i64,
title: String,
description: String,
type_id: Uuid,
status_id: Uuid,
priority: String,
assignee_principal_id: Option<Uuid>,
parent_issue_id: Option<Uuid>,
author_principal_id: Uuid,
version: i64,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}

impl TryFrom<IssueRow> for Issue {
type Error = StoreError;

fn try_from(row: IssueRow) -> Result<Self, Self::Error> {
let priority = match row.priority.as_str() {
"none" => IssuePriority::None,
"low" => IssuePriority::Low,
"normal" => IssuePriority::Normal,
"high" => IssuePriority::High,
"urgent" => IssuePriority::Urgent,
value => {
return Err(StoreError::Rejected(format!(
"unknown issue priority {value}"
)));
}
};
Ok(Self {
id: row.id,
project_id: row.project_id,
number: row.number,
title: row.title,
description: row.description,
type_id: row.type_id,
status_id: row.status_id,
priority,
assignee_principal_id: row.assignee_principal_id,
parent_issue_id: row.parent_issue_id,
author_principal_id: row.author_principal_id,
version: row.version,
created_at: row.created_at,
updated_at: row.updated_at,
})
}
}

const COLUMNS: &str = "i.id, i.project_id, i.number, i.title, i.description, \
i.type_id, i.status_id, i.priority, i.assignee_principal_id, i.parent_issue_id, \
i.author_principal_id, i.version, i.created_at, i.updated_at";

pub async fn create(
pool: &PgPool,
principal_id: Uuid,
command: &CreateIssue,
) -> Result<Issue, StoreError> {
let mut transaction = pool.begin().await.map_err(unavailable)?;
let project: Option<(i64, Uuid, Uuid)> = sqlx::query_as(
"UPDATE project p SET next_issue_number = next_issue_number + 1, \
version = version + 1, updated_at = now() FROM workspace_access a \
WHERE p.id = $1 AND a.workspace_id = p.workspace_id AND a.principal_id = $2 \
AND 'write' = ANY(a.capabilities) \
RETURNING p.next_issue_number - 1, p.default_status_id, \
(SELECT id FROM issue_type WHERE project_id = p.id ORDER BY name LIMIT 1)",
)
.bind(command.project_id)
.bind(principal_id)
.fetch_optional(&mut *transaction)
.await
.map_err(unavailable)?;
let (number, status_id, type_id) = project.ok_or(StoreError::NotFound)?;
let priority = match command.priority {
IssuePriority::None => "none",
IssuePriority::Low => "low",
IssuePriority::Normal => "normal",
IssuePriority::High => "high",
IssuePriority::Urgent => "urgent",
};
let issue_id = Uuid::new_v4();
let row = sqlx::query_as::<_, IssueRow>(
"INSERT INTO issue (id, project_id, number, title, description, type_id, \
status_id, priority, author_principal_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) \
RETURNING id, project_id, number, title, description, type_id, status_id, \
priority, assignee_principal_id, parent_issue_id, author_principal_id, \
version, created_at, updated_at",
)
.bind(issue_id)
.bind(command.project_id)
.bind(number)
.bind(&command.title)
.bind(&command.description)
.bind(type_id)
.bind(status_id)
.bind(priority)
.bind(principal_id)
.fetch_one(&mut *transaction)
.await
.map_err(rejected)?;
outbox(
&mut transaction,
"collaboration.issue.created",
issue_id,
serde_json::json!({"issue_id": issue_id, "project_id": command.project_id, "number": number, "actor_id": principal_id}),
)
.await?;
transaction.commit().await.map_err(unavailable)?;
row.try_into()
}

pub async fn list(
pool: &PgPool,
principal_id: Uuid,
project_id: Uuid,
) -> Result<Vec<Issue>, StoreError> {
let query = format!(
"SELECT {COLUMNS} FROM issue i JOIN project p ON p.id = i.project_id \
JOIN workspace_access a ON a.workspace_id = p.workspace_id \
WHERE i.project_id = $1 AND a.principal_id = $2 \
AND 'read' = ANY(a.capabilities) ORDER BY i.number"
);
sqlx::query_as::<_, IssueRow>(&query)
.bind(project_id)
.bind(principal_id)
.fetch_all(pool)
.await
.map_err(unavailable)?
.into_iter()
.map(TryInto::try_into)
.collect()
}
+172
View File
@@ -1,0 +1,172 @@
use sqlx::{FromRow, PgPool};
use syncode_collab_application::{CreateProject, StoreError};
use syncode_collab_model::{Project, ProjectKind, ProjectLifecycle};
use uuid::Uuid;

use crate::working::{outbox, rejected, unavailable};

#[derive(FromRow)]
struct ProjectRow {
id: Uuid,
workspace_id: Uuid,
key: String,
name: String,
slug: String,
description: String,
kind: String,
lifecycle: String,
default_status_id: Uuid,
completion_status_id: Uuid,
version: i64,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}

impl TryFrom<ProjectRow> for Project {
type Error = StoreError;

fn try_from(row: ProjectRow) -> Result<Self, Self::Error> {
let kind = match row.kind.as_str() {
"continuous" => ProjectKind::Continuous,
"finite" => ProjectKind::Finite,
value => {
return Err(StoreError::Rejected(format!(
"unknown project kind {value}"
)));
}
};
let lifecycle = match row.lifecycle.as_str() {
"planned" => ProjectLifecycle::Planned,
"active" => ProjectLifecycle::Active,
"completed" => ProjectLifecycle::Completed,
"canceled" => ProjectLifecycle::Canceled,
value => {
return Err(StoreError::Rejected(format!(
"unknown project lifecycle {value}"
)));
}
};
Ok(Self {
id: row.id,
workspace_id: row.workspace_id,
key: row.key,
name: row.name,
slug: row.slug,
description: row.description,
kind,
lifecycle,
default_status_id: row.default_status_id,
completion_status_id: row.completion_status_id,
version: row.version,
created_at: row.created_at,
updated_at: row.updated_at,
})
}
}

const COLUMNS: &str = "p.id, p.workspace_id, p.key, p.name, p.slug, p.description, \
p.kind, p.lifecycle, p.default_status_id, p.completion_status_id, p.version, \
p.created_at, p.updated_at";

pub async fn create(
pool: &PgPool,
principal_id: Uuid,
command: &CreateProject,
) -> Result<Project, StoreError> {
let mut transaction = pool.begin().await.map_err(unavailable)?;
let allowed: Option<(bool,)> = sqlx::query_as(
"SELECT true FROM workspace_access WHERE workspace_id = $1 \
AND principal_id = $2 AND 'write' = ANY(capabilities)",
)
.bind(command.workspace_id)
.bind(principal_id)
.fetch_optional(&mut *transaction)
.await
.map_err(unavailable)?;
if allowed.is_none() {
return Err(StoreError::NotFound);
}
let project_id = Uuid::new_v4();
let statuses = [
(Uuid::new_v4(), "Backlog", "backlog", 0),
(Uuid::new_v4(), "Todo", "unstarted", 1),
(Uuid::new_v4(), "In Progress", "started", 2),
(Uuid::new_v4(), "Done", "completed", 3),
(Uuid::new_v4(), "Canceled", "canceled", 4),
];
let default_status_id = statuses[0].0;
let completion_status_id = statuses[3].0;
let kind = match command.kind {
ProjectKind::Continuous => "continuous",
ProjectKind::Finite => "finite",
};
let row = sqlx::query_as::<_, ProjectRow>(
"INSERT INTO project (id, workspace_id, key, name, slug, description, kind, \
default_status_id, completion_status_id) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9) \
RETURNING id, workspace_id, key, name, slug, description, kind, lifecycle, \
default_status_id, completion_status_id, version, created_at, updated_at",
)
.bind(project_id)
.bind(command.workspace_id)
.bind(&command.key)
.bind(&command.name)
.bind(&command.slug)
.bind(&command.description)
.bind(kind)
.bind(default_status_id)
.bind(completion_status_id)
.fetch_one(&mut *transaction)
.await
.map_err(rejected)?;
for (id, name, category, position) in statuses {
sqlx::query(
"INSERT INTO issue_status (id, project_id, name, category, position) \
VALUES ($1, $2, $3, $4, $5)",
)
.bind(id)
.bind(project_id)
.bind(name)
.bind(category)
.bind(position)
.execute(&mut *transaction)
.await
.map_err(rejected)?;
}
sqlx::query("INSERT INTO issue_type (id, project_id, name) VALUES ($1, $2, 'Issue')")
.bind(Uuid::new_v4())
.bind(project_id)
.execute(&mut *transaction)
.await
.map_err(rejected)?;
outbox(
&mut transaction,
"collaboration.project.created",
project_id,
serde_json::json!({"project_id": project_id, "workspace_id": command.workspace_id, "actor_id": principal_id}),
)
.await?;
transaction.commit().await.map_err(unavailable)?;
row.try_into()
}

pub async fn list(
pool: &PgPool,
principal_id: Uuid,
workspace_id: Uuid,
) -> Result<Vec<Project>, StoreError> {
let query = format!(
"SELECT {COLUMNS} FROM project p \
JOIN workspace_access a ON a.workspace_id = p.workspace_id \
WHERE p.workspace_id = $1 AND a.principal_id = $2 \
AND 'read' = ANY(a.capabilities) ORDER BY p.name, p.id"
);
sqlx::query_as::<_, ProjectRow>(&query)
.bind(workspace_id)
.bind(principal_id)
.fetch_all(pool)
.await
.map_err(unavailable)?
.into_iter()
.map(TryInto::try_into)
.collect()
}
+43
View File
@@ -1,0 +1,43 @@
use sqlx::FromRow;
use syncode_collab_application::StoreError;
use syncode_collab_model::{OwnerKind, Workspace};
use uuid::Uuid;

#[derive(FromRow)]
pub(crate) struct WorkspaceRow {
id: Uuid,
owner_kind: String,
owner_id: Uuid,
name: String,
slug: String,
default_environment: String,
version: i64,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
}

impl TryFrom<WorkspaceRow> for Workspace {
type Error = StoreError;

fn try_from(row: WorkspaceRow) -> Result<Self, Self::Error> {
let owner_kind = match row.owner_kind.as_str() {
"user" => OwnerKind::User,
"organization" => OwnerKind::Organization,
value => return Err(StoreError::Rejected(format!("unknown owner kind {value}"))),
};
Ok(Self {
id: row.id,
owner_kind,
owner_id: row.owner_id,
name: row.name,
slug: row.slug,
default_environment: row.default_environment,
version: row.version,
created_at: row.created_at,
updated_at: row.updated_at,
})
}
}

pub(crate) const WORKSPACE_COLUMNS: &str = "w.id, w.owner_kind, w.owner_id, w.name, w.slug, \
w.default_environment, w.version, w.created_at, w.updated_at";
+106
View File
@@ -1,0 +1,106 @@
use sqlx::{FromRow, PgPool};
use syncode_collab_application::{CreateVersion, StoreError};
use syncode_collab_model::{Version, VersionState};
use uuid::Uuid;

use crate::working::{outbox, rejected, unavailable};

#[derive(FromRow)]
struct VersionRow {
id: Uuid,
project_id: Uuid,
name: String,
slug: String,
state: String,
created_at: chrono::DateTime<chrono::Utc>,
released_at: Option<chrono::DateTime<chrono::Utc>>,
}

impl TryFrom<VersionRow> for Version {
type Error = StoreError;

fn try_from(row: VersionRow) -> Result<Self, Self::Error> {
let state = match row.state.as_str() {
"planned" => VersionState::Planned,
"released" => VersionState::Released,
value => {
return Err(StoreError::Rejected(format!(
"unknown version state {value}"
)));
}
};
Ok(Self {
id: row.id,
project_id: row.project_id,
name: row.name,
slug: row.slug,
state,
created_at: row.created_at,
released_at: row.released_at,
})
}
}

const COLUMNS: &str = "v.id, v.project_id, v.name, v.slug, v.state, v.created_at, v.released_at";

pub async fn create(
pool: &PgPool,
principal_id: Uuid,
command: &CreateVersion,
) -> Result<Version, StoreError> {
let mut transaction = pool.begin().await.map_err(unavailable)?;
let allowed: Option<(bool,)> = sqlx::query_as(
"SELECT true FROM project p JOIN workspace_access a ON a.workspace_id = p.workspace_id \
WHERE p.id = $1 AND a.principal_id = $2 AND 'write' = ANY(a.capabilities)",
)
.bind(command.project_id)
.bind(principal_id)
.fetch_optional(&mut *transaction)
.await
.map_err(unavailable)?;
if allowed.is_none() {
return Err(StoreError::NotFound);
}
let id = Uuid::new_v4();
let row = sqlx::query_as::<_, VersionRow>(
"INSERT INTO project_version (id, project_id, name, slug) VALUES ($1,$2,$3,$4) \
RETURNING id, project_id, name, slug, state, created_at, released_at",
)
.bind(id)
.bind(command.project_id)
.bind(&command.name)
.bind(&command.slug)
.fetch_one(&mut *transaction)
.await
.map_err(rejected)?;
outbox(
&mut transaction,
"collaboration.version.created",
id,
serde_json::json!({"version_id": id, "project_id": command.project_id, "actor_id": principal_id}),
)
.await?;
transaction.commit().await.map_err(unavailable)?;
row.try_into()
}

pub async fn list(
pool: &PgPool,
principal_id: Uuid,
project_id: Uuid,
) -> Result<Vec<Version>, StoreError> {
let query = format!(
"SELECT {COLUMNS} FROM project_version v JOIN project p ON p.id = v.project_id \
JOIN workspace_access a ON a.workspace_id = p.workspace_id WHERE v.project_id = $1 \
AND a.principal_id = $2 AND 'read' = ANY(a.capabilities) ORDER BY v.created_at, v.id"
);
sqlx::query_as::<_, VersionRow>(&query)
.bind(project_id)
.bind(principal_id)
.fetch_all(pool)
.await
.map_err(unavailable)?
.into_iter()
.map(TryInto::try_into)
.collect()
}
@@ -1,0 +1,202 @@
use sqlx::PgPool;
use syncode_collab_application::ports::StoredRepository;
use syncode_collab_application::{CreateWorkspace, StoreError};
use syncode_collab_model::{OwnerKind, RepositoryRole, Workspace};
use uuid::Uuid;

use crate::working::{outbox, rejected, unavailable};
use crate::working_rows::{WORKSPACE_COLUMNS, WorkspaceRow};

pub async fn create(
pool: &PgPool,
principal_id: Uuid,
command: &CreateWorkspace,
) -> Result<Workspace, StoreError> {
let mut transaction = pool.begin().await.map_err(unavailable)?;
let workspace_id = Uuid::new_v4();
let environment_id = Uuid::new_v4();
let owner_kind = match command.owner_kind {
OwnerKind::User => "user",
OwnerKind::Organization => "organization",
};
let row = sqlx::query_as::<_, WorkspaceRow>(
"INSERT INTO workspace \
(id, owner_kind, owner_id, name, slug, default_environment) \
VALUES ($1, $2, $3, $4, $5, $6) \
RETURNING id, owner_kind, owner_id, name, slug, default_environment, \
version, created_at, updated_at",
)
.bind(workspace_id)
.bind(owner_kind)
.bind(command.owner_id)
.bind(&command.name)
.bind(&command.slug)
.bind(&command.default_environment)
.fetch_one(&mut *transaction)
.await
.map_err(rejected)?;
sqlx::query(
"INSERT INTO workspace_access (workspace_id, principal_id, capabilities) \
VALUES ($1, $2, ARRAY['read', 'write', 'admin'])",
)
.bind(workspace_id)
.bind(principal_id)
.execute(&mut *transaction)
.await
.map_err(rejected)?;
sqlx::query(
"INSERT INTO workspace_environment \
(id, workspace_id, name, default_source_branch) VALUES ($1, $2, $3, $4)",
)
.bind(environment_id)
.bind(workspace_id)
.bind(&command.default_environment)
.bind(&command.default_source_branch)
.execute(&mut *transaction)
.await
.map_err(rejected)?;
outbox(
&mut transaction,
"collaboration.workspace.created",
workspace_id,
serde_json::json!({"workspace_id": workspace_id, "actor_id": principal_id}),
)
.await?;
transaction.commit().await.map_err(unavailable)?;
row.try_into()
}

pub async fn list(pool: &PgPool, principal_id: Uuid) -> Result<Vec<Workspace>, StoreError> {
let query = format!(
"SELECT {WORKSPACE_COLUMNS} FROM workspace w \
JOIN workspace_access a ON a.workspace_id = w.id \
WHERE a.principal_id = $1 AND 'read' = ANY(a.capabilities) ORDER BY w.name, w.id"
);
sqlx::query_as::<_, WorkspaceRow>(&query)
.bind(principal_id)
.fetch_all(pool)
.await
.map_err(unavailable)?
.into_iter()
.map(TryInto::try_into)
.collect()
}

pub async fn get(
pool: &PgPool,
principal_id: Uuid,
workspace_id: Uuid,
) -> Result<Workspace, StoreError> {
let query = format!(
"SELECT {WORKSPACE_COLUMNS} FROM workspace w \
JOIN workspace_access a ON a.workspace_id = w.id \
WHERE w.id = $1 AND a.principal_id = $2 AND 'read' = ANY(a.capabilities)"
);
sqlx::query_as::<_, WorkspaceRow>(&query)
.bind(workspace_id)
.bind(principal_id)
.fetch_optional(pool)
.await
.map_err(unavailable)?
.ok_or(StoreError::NotFound)?
.try_into()
}

pub async fn attach_repository(
pool: &PgPool,
principal_id: Uuid,
workspace_id: Uuid,
repository_id: Uuid,
role: RepositoryRole,
branch: &str,
) -> Result<(), StoreError> {
let mut transaction = pool.begin().await.map_err(unavailable)?;
require_write(&mut transaction, principal_id, workspace_id).await?;
let role = match role {
RepositoryRole::Source => "source",
RepositoryRole::Infra => "infra",
};
sqlx::query(
"INSERT INTO workspace_repository (workspace_id, repository_id, role) \
VALUES ($1, $2, $3)",
)
.bind(workspace_id)
.bind(repository_id)
.bind(role)
.execute(&mut *transaction)
.await
.map_err(rejected)?;
if role == "infra" {
sqlx::query("UPDATE workspace_environment SET infra_branch = $2 WHERE workspace_id = $1")
.bind(workspace_id)
.bind(branch)
.execute(&mut *transaction)
.await
.map_err(rejected)?;
} else {
sqlx::query(
"INSERT INTO environment_branch_override (environment_id, repository_id, branch) \
SELECT id, $2, $3 FROM workspace_environment WHERE workspace_id = $1",
)
.bind(workspace_id)
.bind(repository_id)
.bind(branch)
.execute(&mut *transaction)
.await
.map_err(rejected)?;
}
outbox(
&mut transaction,
"collaboration.workspace.repository_attached",
workspace_id,
serde_json::json!({"workspace_id": workspace_id, "repository_id": repository_id, "role": role}),
)
.await?;
transaction.commit().await.map_err(unavailable)
}

pub async fn repositories(
pool: &PgPool,
principal_id: Uuid,
workspace_id: Uuid,
) -> Result<Vec<StoredRepository>, StoreError> {
get(pool, principal_id, workspace_id).await?;
let rows: Vec<(Uuid, String)> = sqlx::query_as(
"SELECT repository_id, role FROM workspace_repository \
WHERE workspace_id = $1 ORDER BY role, repository_id",
)
.bind(workspace_id)
.fetch_all(pool)
.await
.map_err(unavailable)?;
rows.into_iter()
.map(|(repository_id, role)| {
let role = match role.as_str() {
"source" => RepositoryRole::Source,
"infra" => RepositoryRole::Infra,
value => return Err(StoreError::Rejected(format!("unknown role {value}"))),
};
Ok(StoredRepository {
repository_id,
role,
})
})
.collect()
}

pub(crate) async fn require_write(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
principal_id: Uuid,
workspace_id: Uuid,
) -> Result<(), StoreError> {
let allowed: Option<(bool,)> = sqlx::query_as(
"SELECT true FROM workspace_access WHERE workspace_id = $1 \
AND principal_id = $2 AND 'write' = ANY(capabilities)",
)
.bind(workspace_id)
.bind(principal_id)
.fetch_optional(&mut **transaction)
.await
.map_err(unavailable)?;
allowed.map(|_| ()).ok_or(StoreError::NotFound)
}
@@ -1,0 +1,216 @@
use syncode_collab_application::ports::CollaborationStore;
use syncode_collab_application::{
ConfigureEnvironment, CreateDocument, CreateIssue, CreateProject, CreateVersion,
CreateWorkspace, StoreError,
};
use syncode_collab_model::{IssuePriority, OwnerKind, ProjectKind, RepositoryRole};
use syncode_collab_store::{PostgresCollaboration, connect, migrate};
use uuid::Uuid;

async fn store() -> Option<(PostgresCollaboration, sqlx::PgPool)> {
let url = std::env::var("SYNCODE_COLLAB_TEST_DATABASE_URL").ok()?;
let pool = connect(&url, 4).await.ok()?;
migrate(&pool).await.ok()?;
sqlx::query(
"TRUNCATE issue, document_revision, document, project_version, issue_type, issue_status, project, \
environment_branch_override, workspace_environment, workspace_repository, \
workspace_access, workspace, event_outbox RESTART IDENTITY CASCADE",
)
.execute(&pool)
.await
.ok()?;
Some((PostgresCollaboration::new(pool.clone()), pool))
}

async fn workspace(
store: &PostgresCollaboration,
principal_id: Uuid,
) -> Result<syncode_collab_model::Workspace, StoreError> {
store
.create_workspace(
principal_id,
&CreateWorkspace {
owner_kind: OwnerKind::User,
owner_id: principal_id,
name: "SynCode".to_owned(),
slug: "syncode".to_owned(),
default_environment: "development".to_owned(),
default_source_branch: "develop".to_owned(),
},
)
.await
}

async fn project(
store: &PostgresCollaboration,
principal_id: Uuid,
workspace_id: Uuid,
) -> Result<syncode_collab_model::Project, StoreError> {
store
.create_project(
principal_id,
&CreateProject {
workspace_id,
key: "COL".to_owned(),
name: "Collaboration plane".to_owned(),
slug: "collaboration-plane".to_owned(),
description: String::new(),
kind: ProjectKind::Finite,
},
)
.await
}

#[tokio::test]
async fn creates_a_working_workspace_and_enforces_its_invariants()
-> Result<(), Box<dyn std::error::Error>> {
let Some((store, _pool)) = store().await else {
return Ok(());
};
let principal_id = Uuid::new_v4();
let workspace = workspace(&store, principal_id).await?;
let source_id = Uuid::new_v4();
store
.attach_repository(
principal_id,
workspace.id,
source_id,
RepositoryRole::Source,
"feature/workspace",
)
.await?;
store
.attach_repository(
principal_id,
workspace.id,
Uuid::new_v4(),
RepositoryRole::Infra,
"main",
)
.await?;
assert!(matches!(
store
.attach_repository(
principal_id,
workspace.id,
Uuid::new_v4(),
RepositoryRole::Infra,
"main",
)
.await,
Err(StoreError::Rejected(_))
));
let environment = store
.environment(principal_id, workspace.id, "development")
.await?;
assert_eq!(Some("main"), environment.infra_branch.as_deref());
assert_eq!(
Some(&(source_id, "feature/workspace".to_owned())),
environment.branch_overrides.first()
);
let production = store
.configure_environment(
principal_id,
&ConfigureEnvironment {
workspace_id: workspace.id,
name: "production".to_owned(),
default_source_branch: "main".to_owned(),
infra_branch: Some("main".to_owned()),
},
)
.await?;
assert_eq!("production", production.name);
assert_eq!(
2,
store
.list_environments(principal_id, workspace.id)
.await?
.len()
);
assert!(store.list_workspaces(Uuid::new_v4()).await?.is_empty());
Ok(())
}

#[tokio::test]
async fn rejects_stale_documents_and_allocates_issue_numbers()
-> Result<(), Box<dyn std::error::Error>> {
let Some((store, _pool)) = store().await else {
return Ok(());
};
let principal_id = Uuid::new_v4();
let workspace = workspace(&store, principal_id).await?;
let project = project(&store, principal_id, workspace.id).await?;
store
.create_version(
principal_id,
&CreateVersion {
project_id: project.id,
name: "0.6.0".to_owned(),
slug: "v0-6-0".to_owned(),
},
)
.await?;
assert_eq!(
1,
store.list_versions(principal_id, project.id).await?.len()
);
let first = store
.write_document(
principal_id,
&CreateDocument {
workspace_id: workspace.id,
project_id: Some(project.id),
path: "README.md".to_owned(),
content: "first".to_owned(),
base_revision_id: None,
},
)
.await?;
let second = store
.write_document(
principal_id,
&CreateDocument {
workspace_id: workspace.id,
project_id: Some(project.id),
path: "README.md".to_owned(),
content: "second".to_owned(),
base_revision_id: Some(first.1.id),
},
)
.await?;
assert!(matches!(
store
.write_document(
principal_id,
&CreateDocument {
workspace_id: workspace.id,
project_id: Some(project.id),
path: "README.md".to_owned(),
content: "stale".to_owned(),
base_revision_id: Some(first.1.id),
},
)
.await,
Err(StoreError::Conflict(_))
));
assert_eq!("second", second.1.content);
for title in ["First", "Second"] {
store
.create_issue(
principal_id,
&CreateIssue {
project_id: project.id,
title: title.to_owned(),
description: String::new(),
priority: IssuePriority::Normal,
},
)
.await?;
}
let issues = store.list_issues(principal_id, project.id).await?;
assert_eq!(
vec![1, 2],
issues.iter().map(|issue| issue.number).collect::<Vec<_>>()
);
Ok(())
}
+182
View File
@@ -1,0 +1,182 @@
use syncode_collab_api_grpc::identity_proto::identity_client::IdentityClient;
use syncode_collab_api_grpc::identity_proto::{
CheckCapabilityRequest, GetRepositoryCoordinatesRequest, PrincipalKind as WirePrincipalKind,
ResolveRepositoryRequest, ResourceKind, ValidateSessionRequest,
};
use syncode_collab_application::ports::{AccessError, IdentityAccess};
use syncode_collab_application::{Principal, PrincipalKind, RepositoryCoordinates};
use tonic::metadata::{Ascii, MetadataValue};
use tonic::transport::Channel;
use tonic::{Code, Request};
use uuid::Uuid;

#[derive(Clone)]
pub struct IdentityClientAccess {
client: IdentityClient<Channel>,
authorization: MetadataValue<Ascii>,
}

impl IdentityClientAccess {
pub async fn connect(endpoint: String, shared_secret: String) -> Result<Self, AccessError> {
let authorization = format!("Bearer {shared_secret}")
.parse()
.map_err(|_| AccessError::Unavailable("identity secret is invalid".to_owned()))?;
let client = IdentityClient::connect(endpoint)
.await
.map_err(|error| AccessError::Unavailable(error.to_string()))?;
Ok(Self {
client,
authorization,
})
}

fn request<T>(&self, body: T) -> Request<T> {
let mut request = Request::new(body);
request
.metadata_mut()
.insert("authorization", self.authorization.clone());
request
}

async fn require(
&self,
principal: Principal,
resource_kind: ResourceKind,
resource_id: Uuid,
capability: &str,
) -> Result<(), AccessError> {
let allowed = self
.client
.clone()
.check_capability(self.request(CheckCapabilityRequest {
principal_id: principal.id.to_string(),
principal_kind: wire_principal_kind(principal.kind).into(),
resource_kind: resource_kind.into(),
resource_id: resource_id.to_string(),
capability: capability.to_owned(),
}))
.await
.map_err(map_status)?
.into_inner()
.allowed;
allowed.then_some(()).ok_or(AccessError::Denied)
}
}

#[tonic::async_trait]
impl IdentityAccess for IdentityClientAccess {
async fn authenticate(&self, session_token: &str) -> Result<Principal, AccessError> {
let response = self
.client
.clone()
.validate_session(self.request(ValidateSessionRequest {
session_token: session_token.to_owned(),
}))
.await
.map_err(map_status)?
.into_inner();
let id = response.principal_id.parse().map_err(|_| {
AccessError::Unavailable("identity returned an invalid principal".to_owned())
})?;
let kind = match WirePrincipalKind::try_from(response.principal_kind) {
Ok(WirePrincipalKind::User) => PrincipalKind::User,
Ok(WirePrincipalKind::PlatformAgent) => PrincipalKind::PlatformAgent,
Ok(WirePrincipalKind::LocalAgent) => PrincipalKind::LocalAgent,
Ok(WirePrincipalKind::AccessToken) => PrincipalKind::AccessToken,
Ok(WirePrincipalKind::Unspecified) | Err(_) => {
return Err(AccessError::Unavailable(
"identity returned an invalid principal kind".to_owned(),
));
}
};
Ok(Principal { id, kind })
}

async fn require_organization_admin(
&self,
principal: Principal,
organization_id: Uuid,
) -> Result<(), AccessError> {
self.require(
principal,
ResourceKind::Organization,
organization_id,
"admin",
)
.await
}

async fn resolve_readable_repository(
&self,
principal: Principal,
owner: &str,
name: &str,
) -> Result<RepositoryCoordinates, AccessError> {
let response = self
.client
.clone()
.resolve_repository(self.request(ResolveRepositoryRequest {
owner: owner.to_owned(),
name: name.to_owned(),
}))
.await
.map_err(map_status)?
.into_inner();
let id = response.repository_id.parse().map_err(|_| {
AccessError::Unavailable("identity returned an invalid repository".to_owned())
})?;
self.require(principal, ResourceKind::Repository, id, "repo:read")
.await?;
Ok(RepositoryCoordinates {
id,
owner: owner.to_owned(),
name: name.to_owned(),
})
}

async fn repository_coordinates(
&self,
principal: Principal,
repository_id: Uuid,
) -> Result<RepositoryCoordinates, AccessError> {
self.require(
principal,
ResourceKind::Repository,
repository_id,
"repo:read",
)
.await?;
let response = self
.client
.clone()
.get_repository_coordinates(self.request(GetRepositoryCoordinatesRequest {
repository_id: repository_id.to_string(),
}))
.await
.map_err(map_status)?
.into_inner();
Ok(RepositoryCoordinates {
id: repository_id,
owner: response.owner,
name: response.name,
})
}
}

fn wire_principal_kind(kind: PrincipalKind) -> WirePrincipalKind {
match kind {
PrincipalKind::User => WirePrincipalKind::User,
PrincipalKind::PlatformAgent => WirePrincipalKind::PlatformAgent,
PrincipalKind::LocalAgent => WirePrincipalKind::LocalAgent,
PrincipalKind::AccessToken => WirePrincipalKind::AccessToken,
}
}

fn map_status(status: tonic::Status) -> AccessError {
match status.code() {
Code::Unauthenticated => AccessError::Unauthenticated,
Code::PermissionDenied => AccessError::Denied,
Code::NotFound => AccessError::NotFound,
_ => AccessError::Unavailable(status.to_string()),
}
}