logiguard fork: GPUI xdg-activation keyboard-focus serial fix
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Some checks failed
Update All Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Triage Project Sync (#84) / Sync triage project (push) Has been cancelled
release_nightly / notify_on_failure (push) Has been cancelled
release_nightly / check_style (push) Has been cancelled
release_nightly / run_tests_windows (push) Has been cancelled
release_nightly / clippy_windows (push) Has been cancelled
release_nightly / bundle_linux_aarch64 (push) Has been cancelled
release_nightly / bundle_linux_x86_64 (push) Has been cancelled
release_nightly / bundle_mac_aarch64 (push) Has been cancelled
release_nightly / bundle_mac_x86_64 (push) Has been cancelled
release_nightly / bundle_windows_aarch64 (push) Has been cancelled
release_nightly / bundle_windows_x86_64 (push) Has been cancelled
release_nightly / build_nix_linux_x86_64 (push) Has been cancelled
release_nightly / build_nix_mac_aarch64 (push) Has been cancelled
release_nightly / update_nightly_tag (push) Has been cancelled
Hotfix Review Monitor / check-hotfix-reviews (push) Has been cancelled
Stale PR Review Reminder / check-stale-prs (push) Has been cancelled
Update Weekly Top Ranking Issues / update_top_ranking_issues (push) Has been cancelled
Bump collab-staging Tag / update-collab-staging-tag (push) Has been cancelled
compliance_check / scheduled_compliance_check (push) Has been cancelled
Single-commit orphan branch: full zed-industries/zed @ 8c74db0 source tree with a 3-file patch applied (no upstream history). Patch (crates/gpui_linux/src/linux/wayland/): - serial.rs: add SerialKind::KeyboardEnter - client.rs: store wl_keyboard.enter serial; add latest_serial_of() - window.rs: activate() uses keyboard-enter serial (Mutter focus gate) Mutter honors window activation only when the token carries the keyboard- focus serial from wl_keyboard.enter; GPUI used a stale mouse-press serial. See docs/tray-window-focus-wayland.md in logiguard. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
74
crates/collab/src/api.rs
Normal file
74
crates/collab/src/api.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
pub mod events;
|
||||
pub mod extensions;
|
||||
|
||||
use crate::Result;
|
||||
use axum::{headers::Header, http::HeaderName};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub use extensions::fetch_extensions_from_blob_store_periodically;
|
||||
|
||||
pub struct CloudflareIpCountryHeader(String);
|
||||
|
||||
impl Header for CloudflareIpCountryHeader {
|
||||
fn name() -> &'static HeaderName {
|
||||
static CLOUDFLARE_IP_COUNTRY_HEADER: OnceLock<HeaderName> = OnceLock::new();
|
||||
CLOUDFLARE_IP_COUNTRY_HEADER.get_or_init(|| HeaderName::from_static("cf-ipcountry"))
|
||||
}
|
||||
|
||||
fn decode<'i, I>(values: &mut I) -> Result<Self, axum::headers::Error>
|
||||
where
|
||||
Self: Sized,
|
||||
I: Iterator<Item = &'i axum::http::HeaderValue>,
|
||||
{
|
||||
let country_code = values
|
||||
.next()
|
||||
.ok_or_else(axum::headers::Error::invalid)?
|
||||
.to_str()
|
||||
.map_err(|_| axum::headers::Error::invalid())?;
|
||||
|
||||
Ok(Self(country_code.to_string()))
|
||||
}
|
||||
|
||||
fn encode<E: Extend<axum::http::HeaderValue>>(&self, _values: &mut E) {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CloudflareIpCountryHeader {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SystemIdHeader(String);
|
||||
|
||||
impl Header for SystemIdHeader {
|
||||
fn name() -> &'static HeaderName {
|
||||
static SYSTEM_ID_HEADER: OnceLock<HeaderName> = OnceLock::new();
|
||||
SYSTEM_ID_HEADER.get_or_init(|| HeaderName::from_static("x-zed-system-id"))
|
||||
}
|
||||
|
||||
fn decode<'i, I>(values: &mut I) -> Result<Self, axum::headers::Error>
|
||||
where
|
||||
Self: Sized,
|
||||
I: Iterator<Item = &'i axum::http::HeaderValue>,
|
||||
{
|
||||
let system_id = values
|
||||
.next()
|
||||
.ok_or_else(axum::headers::Error::invalid)?
|
||||
.to_str()
|
||||
.map_err(|_| axum::headers::Error::invalid())?;
|
||||
|
||||
Ok(Self(system_id.to_string()))
|
||||
}
|
||||
|
||||
fn encode<E: Extend<axum::http::HeaderValue>>(&self, _values: &mut E) {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SystemIdHeader {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
232
crates/collab/src/api/events.rs
Normal file
232
crates/collab/src/api/events.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
use crate::api::CloudflareIpCountryHeader;
|
||||
use crate::{AppState, Error, Result};
|
||||
use anyhow::anyhow;
|
||||
use axum::{
|
||||
Extension, Router, TypedHeader,
|
||||
body::Bytes,
|
||||
headers::Header,
|
||||
http::{HeaderName, StatusCode},
|
||||
routing::post,
|
||||
};
|
||||
use chrono::Duration;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
use telemetry_events::{Event, EventRequestBody};
|
||||
use util::ResultExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/telemetry/events", post(post_events))
|
||||
.route("/telemetry/crashes", post(post_panic))
|
||||
.route("/telemetry/panics", post(post_panic))
|
||||
.route("/telemetry/hangs", post(post_panic))
|
||||
}
|
||||
|
||||
pub struct ZedChecksumHeader(Vec<u8>);
|
||||
|
||||
impl Header for ZedChecksumHeader {
|
||||
fn name() -> &'static HeaderName {
|
||||
static ZED_CHECKSUM_HEADER: OnceLock<HeaderName> = OnceLock::new();
|
||||
ZED_CHECKSUM_HEADER.get_or_init(|| HeaderName::from_static("x-zed-checksum"))
|
||||
}
|
||||
|
||||
fn decode<'i, I>(values: &mut I) -> Result<Self, axum::headers::Error>
|
||||
where
|
||||
Self: Sized,
|
||||
I: Iterator<Item = &'i axum::http::HeaderValue>,
|
||||
{
|
||||
let checksum = values
|
||||
.next()
|
||||
.ok_or_else(axum::headers::Error::invalid)?
|
||||
.to_str()
|
||||
.map_err(|_| axum::headers::Error::invalid())?;
|
||||
|
||||
let bytes = hex::decode(checksum).map_err(|_| axum::headers::Error::invalid())?;
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
|
||||
fn encode<E: Extend<axum::http::HeaderValue>>(&self, _values: &mut E) {
|
||||
unimplemented!()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn post_panic() -> Result<()> {
|
||||
// as of v0.201.x crash/panic reporting is now done via Sentry.
|
||||
// The endpoint returns OK to avoid spurious errors for old clients.
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn post_events(
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
TypedHeader(ZedChecksumHeader(checksum)): TypedHeader<ZedChecksumHeader>,
|
||||
country_code_header: Option<TypedHeader<CloudflareIpCountryHeader>>,
|
||||
body: Bytes,
|
||||
) -> Result<()> {
|
||||
let Some(expected) = calculate_json_checksum(app.clone(), &body) else {
|
||||
return Err(Error::http(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"events not enabled".into(),
|
||||
))?;
|
||||
};
|
||||
|
||||
let checksum_matched = checksum == expected;
|
||||
|
||||
let request_body: telemetry_events::EventRequestBody =
|
||||
serde_json::from_slice(&body).map_err(|err| {
|
||||
log::error!("can't parse event json: {err}");
|
||||
Error::Internal(anyhow!(err))
|
||||
})?;
|
||||
|
||||
let Some(last_event) = request_body.events.last() else {
|
||||
return Err(Error::http(StatusCode::BAD_REQUEST, "no events".into()))?;
|
||||
};
|
||||
let country_code = country_code_header.map(|h| h.to_string());
|
||||
|
||||
let first_event_at = chrono::Utc::now()
|
||||
- chrono::Duration::milliseconds(last_event.milliseconds_since_first_event);
|
||||
|
||||
if let Some(kinesis_client) = app.kinesis_client.clone()
|
||||
&& let Some(stream) = app.config.kinesis_stream.clone()
|
||||
{
|
||||
let mut request = kinesis_client.put_records().stream_name(stream);
|
||||
let mut has_records = false;
|
||||
for row in for_snowflake(
|
||||
request_body.clone(),
|
||||
first_event_at,
|
||||
country_code.clone(),
|
||||
checksum_matched,
|
||||
) {
|
||||
if let Some(data) = serde_json::to_vec(&row).log_err() {
|
||||
request = request.records(
|
||||
aws_sdk_kinesis::types::PutRecordsRequestEntry::builder()
|
||||
.partition_key(request_body.system_id.clone().unwrap_or_default())
|
||||
.data(data.into())
|
||||
.build()
|
||||
.unwrap(),
|
||||
);
|
||||
has_records = true;
|
||||
}
|
||||
}
|
||||
if has_records {
|
||||
request.send().await.log_err();
|
||||
}
|
||||
};
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn calculate_json_checksum(app: Arc<AppState>, json: &impl AsRef<[u8]>) -> Option<Vec<u8>> {
|
||||
let checksum_seed = app.config.zed_client_checksum_seed.as_ref()?;
|
||||
|
||||
let mut summer = Sha256::new();
|
||||
summer.update(checksum_seed);
|
||||
summer.update(json);
|
||||
summer.update(checksum_seed);
|
||||
Some(summer.finalize().into_iter().collect())
|
||||
}
|
||||
|
||||
fn for_snowflake(
|
||||
body: EventRequestBody,
|
||||
first_event_at: chrono::DateTime<chrono::Utc>,
|
||||
country_code: Option<String>,
|
||||
checksum_matched: bool,
|
||||
) -> impl Iterator<Item = SnowflakeRow> {
|
||||
body.events.into_iter().map(move |event| {
|
||||
let timestamp =
|
||||
first_event_at + Duration::milliseconds(event.milliseconds_since_first_event);
|
||||
let (event_type, mut event_properties) = match &event.event {
|
||||
Event::Flexible(e) => (
|
||||
e.event_type.clone(),
|
||||
serde_json::to_value(&e.event_properties).unwrap(),
|
||||
),
|
||||
};
|
||||
|
||||
if let serde_json::Value::Object(ref mut map) = event_properties {
|
||||
map.insert("app_version".to_string(), body.app_version.clone().into());
|
||||
map.insert("os_name".to_string(), body.os_name.clone().into());
|
||||
map.insert("os_version".to_string(), body.os_version.clone().into());
|
||||
map.insert("architecture".to_string(), body.architecture.clone().into());
|
||||
map.insert(
|
||||
"release_channel".to_string(),
|
||||
body.release_channel.clone().into(),
|
||||
);
|
||||
map.insert("signed_in".to_string(), event.signed_in.into());
|
||||
map.insert("checksum_matched".to_string(), checksum_matched.into());
|
||||
if let Some(country_code) = country_code.as_ref() {
|
||||
map.insert("country".to_string(), country_code.clone().into());
|
||||
}
|
||||
}
|
||||
|
||||
// NOTE: most amplitude user properties are read out of our event_properties
|
||||
// dictionary. See https://app.amplitude.com/data/zed/Zed/sources/detail/production/falcon%3A159998
|
||||
// for how that is configured.
|
||||
let user_properties = body.is_staff.map(|is_staff| {
|
||||
serde_json::json!({
|
||||
"is_staff": is_staff,
|
||||
})
|
||||
});
|
||||
|
||||
SnowflakeRow {
|
||||
time: timestamp,
|
||||
user_id: body.metrics_id.clone(),
|
||||
device_id: body.system_id.clone(),
|
||||
event_type,
|
||||
event_properties,
|
||||
user_properties,
|
||||
insert_id: Some(Uuid::new_v4().to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct SnowflakeRow {
|
||||
pub time: chrono::DateTime<chrono::Utc>,
|
||||
pub user_id: Option<String>,
|
||||
pub device_id: Option<String>,
|
||||
pub event_type: String,
|
||||
pub event_properties: serde_json::Value,
|
||||
pub user_properties: Option<serde_json::Value>,
|
||||
pub insert_id: Option<String>,
|
||||
}
|
||||
|
||||
impl SnowflakeRow {
|
||||
pub fn new(
|
||||
event_type: impl Into<String>,
|
||||
metrics_id: Option<Uuid>,
|
||||
is_staff: bool,
|
||||
system_id: Option<String>,
|
||||
event_properties: serde_json::Value,
|
||||
) -> Self {
|
||||
Self {
|
||||
time: chrono::Utc::now(),
|
||||
event_type: event_type.into(),
|
||||
device_id: system_id,
|
||||
user_id: metrics_id.map(|id| id.to_string()),
|
||||
insert_id: Some(uuid::Uuid::new_v4().to_string()),
|
||||
event_properties,
|
||||
user_properties: Some(json!({"is_staff": is_staff})),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write(
|
||||
self,
|
||||
client: &Option<aws_sdk_kinesis::Client>,
|
||||
stream: &Option<String>,
|
||||
) -> anyhow::Result<()> {
|
||||
let Some((client, stream)) = client.as_ref().zip(stream.as_ref()) else {
|
||||
return Ok(());
|
||||
};
|
||||
let row = serde_json::to_vec(&self)?;
|
||||
client
|
||||
.put_record()
|
||||
.stream_name(stream)
|
||||
.partition_key(&self.user_id.unwrap_or_default())
|
||||
.data(row.into())
|
||||
.send()
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
345
crates/collab/src/api/extensions.rs
Normal file
345
crates/collab/src/api/extensions.rs
Normal file
@@ -0,0 +1,345 @@
|
||||
use crate::db::ExtensionVersionConstraints;
|
||||
use crate::{AppState, Error, Result, db::NewExtensionVersion};
|
||||
use anyhow::Context as _;
|
||||
use aws_sdk_s3::presigning::PresigningConfig;
|
||||
use axum::{
|
||||
Extension, Json, Router,
|
||||
extract::{Path, Query},
|
||||
http::StatusCode,
|
||||
response::Redirect,
|
||||
routing::get,
|
||||
};
|
||||
use cloud_api_types::{ExtensionApiManifest, GetExtensionsResponse};
|
||||
use collections::HashMap;
|
||||
use semver::Version as SemanticVersion;
|
||||
use serde::Deserialize;
|
||||
use std::{sync::Arc, time::Duration};
|
||||
use time::PrimitiveDateTime;
|
||||
use util::{ResultExt, maybe};
|
||||
|
||||
pub fn router() -> Router {
|
||||
Router::new()
|
||||
.route("/extensions/updates", get(get_extension_updates))
|
||||
.route("/extensions/:extension_id", get(get_extension_versions))
|
||||
.route(
|
||||
"/extensions/:extension_id/download",
|
||||
get(download_latest_extension),
|
||||
)
|
||||
.route(
|
||||
"/extensions/:extension_id/:version/download",
|
||||
get(download_extension),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GetExtensionUpdatesParams {
|
||||
ids: String,
|
||||
min_schema_version: i32,
|
||||
max_schema_version: i32,
|
||||
min_wasm_api_version: semver::Version,
|
||||
max_wasm_api_version: semver::Version,
|
||||
}
|
||||
|
||||
async fn get_extension_updates(
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
Query(params): Query<GetExtensionUpdatesParams>,
|
||||
) -> Result<Json<GetExtensionsResponse>> {
|
||||
let constraints = ExtensionVersionConstraints {
|
||||
schema_versions: params.min_schema_version..=params.max_schema_version,
|
||||
wasm_api_versions: params.min_wasm_api_version..=params.max_wasm_api_version,
|
||||
};
|
||||
|
||||
let extension_ids = params.ids.split(',').map(|s| s.trim()).collect::<Vec<_>>();
|
||||
|
||||
let extensions = app
|
||||
.db
|
||||
.get_extensions_by_ids(&extension_ids, Some(&constraints))
|
||||
.await?;
|
||||
|
||||
Ok(Json(GetExtensionsResponse { data: extensions }))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GetExtensionVersionsParams {
|
||||
extension_id: String,
|
||||
}
|
||||
|
||||
async fn get_extension_versions(
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
Path(params): Path<GetExtensionVersionsParams>,
|
||||
) -> Result<Json<GetExtensionsResponse>> {
|
||||
let extension_versions = app.db.get_extension_versions(¶ms.extension_id).await?;
|
||||
|
||||
Ok(Json(GetExtensionsResponse {
|
||||
data: extension_versions,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DownloadLatestExtensionPathParams {
|
||||
extension_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DownloadLatestExtensionQueryParams {
|
||||
min_schema_version: Option<i32>,
|
||||
max_schema_version: Option<i32>,
|
||||
min_wasm_api_version: Option<SemanticVersion>,
|
||||
max_wasm_api_version: Option<SemanticVersion>,
|
||||
}
|
||||
|
||||
async fn download_latest_extension(
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
Path(params): Path<DownloadLatestExtensionPathParams>,
|
||||
Query(query): Query<DownloadLatestExtensionQueryParams>,
|
||||
) -> Result<Redirect> {
|
||||
let constraints = maybe!({
|
||||
let min_schema_version = query.min_schema_version?;
|
||||
let max_schema_version = query.max_schema_version?;
|
||||
let min_wasm_api_version = query.min_wasm_api_version?;
|
||||
let max_wasm_api_version = query.max_wasm_api_version?;
|
||||
|
||||
Some(ExtensionVersionConstraints {
|
||||
schema_versions: min_schema_version..=max_schema_version,
|
||||
wasm_api_versions: min_wasm_api_version..=max_wasm_api_version,
|
||||
})
|
||||
});
|
||||
|
||||
let extension = app
|
||||
.db
|
||||
.get_extension(¶ms.extension_id, constraints.as_ref())
|
||||
.await?
|
||||
.context("unknown extension")?;
|
||||
download_extension(
|
||||
Extension(app),
|
||||
Path(DownloadExtensionParams {
|
||||
extension_id: params.extension_id,
|
||||
version: extension.manifest.version.to_string(),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DownloadExtensionParams {
|
||||
extension_id: String,
|
||||
version: String,
|
||||
}
|
||||
|
||||
async fn download_extension(
|
||||
Extension(app): Extension<Arc<AppState>>,
|
||||
Path(params): Path<DownloadExtensionParams>,
|
||||
) -> Result<Redirect> {
|
||||
let Some((blob_store_client, bucket)) = app
|
||||
.blob_store_client
|
||||
.clone()
|
||||
.zip(app.config.blob_store_bucket.clone())
|
||||
else {
|
||||
Err(Error::http(
|
||||
StatusCode::NOT_IMPLEMENTED,
|
||||
"not supported".into(),
|
||||
))?
|
||||
};
|
||||
|
||||
let DownloadExtensionParams {
|
||||
extension_id,
|
||||
version,
|
||||
} = params;
|
||||
|
||||
let version_exists = app
|
||||
.db
|
||||
.record_extension_download(&extension_id, &version)
|
||||
.await?;
|
||||
|
||||
if !version_exists {
|
||||
Err(Error::http(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown extension version".into(),
|
||||
))?;
|
||||
}
|
||||
|
||||
let url = blob_store_client
|
||||
.get_object()
|
||||
.bucket(bucket)
|
||||
.key(format!(
|
||||
"extensions/{extension_id}/{version}/archive.tar.gz"
|
||||
))
|
||||
.presigned(PresigningConfig::expires_in(EXTENSION_DOWNLOAD_URL_LIFETIME).unwrap())
|
||||
.await
|
||||
.context("creating presigned extension download url")?;
|
||||
|
||||
Ok(Redirect::temporary(url.uri()))
|
||||
}
|
||||
|
||||
const EXTENSION_FETCH_INTERVAL: Duration = Duration::from_secs(5 * 60);
|
||||
const EXTENSION_DOWNLOAD_URL_LIFETIME: Duration = Duration::from_secs(3 * 60);
|
||||
|
||||
pub fn fetch_extensions_from_blob_store_periodically(app_state: Arc<AppState>) {
|
||||
let Some(blob_store_client) = app_state.blob_store_client.clone() else {
|
||||
log::info!("no blob store client");
|
||||
return;
|
||||
};
|
||||
let Some(blob_store_bucket) = app_state.config.blob_store_bucket.clone() else {
|
||||
log::info!("no blob store bucket");
|
||||
return;
|
||||
};
|
||||
|
||||
let executor = app_state.executor.clone();
|
||||
executor.spawn_detached({
|
||||
let executor = executor.clone();
|
||||
async move {
|
||||
loop {
|
||||
fetch_extensions_from_blob_store(
|
||||
&blob_store_client,
|
||||
&blob_store_bucket,
|
||||
&app_state,
|
||||
)
|
||||
.await
|
||||
.log_err();
|
||||
executor.sleep(EXTENSION_FETCH_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn fetch_extensions_from_blob_store(
|
||||
blob_store_client: &aws_sdk_s3::Client,
|
||||
blob_store_bucket: &String,
|
||||
app_state: &Arc<AppState>,
|
||||
) -> anyhow::Result<()> {
|
||||
log::info!("fetching extensions from blob store");
|
||||
|
||||
let mut next_marker = None;
|
||||
let mut published_versions = HashMap::<String, Vec<String>>::default();
|
||||
|
||||
loop {
|
||||
let list = blob_store_client
|
||||
.list_objects()
|
||||
.bucket(blob_store_bucket)
|
||||
.prefix("extensions/")
|
||||
.set_marker(next_marker.clone())
|
||||
.send()
|
||||
.await?;
|
||||
let objects = list.contents.unwrap_or_default();
|
||||
log::info!("fetched {} object(s) from blob store", objects.len());
|
||||
|
||||
for object in &objects {
|
||||
let Some(key) = object.key.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let mut parts = key.split('/');
|
||||
let Some(_) = parts.next().filter(|part| *part == "extensions") else {
|
||||
continue;
|
||||
};
|
||||
let Some(extension_id) = parts.next() else {
|
||||
continue;
|
||||
};
|
||||
let Some(version) = parts.next() else {
|
||||
continue;
|
||||
};
|
||||
if parts.next() == Some("manifest.json") {
|
||||
published_versions
|
||||
.entry(extension_id.to_owned())
|
||||
.or_default()
|
||||
.push(version.to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(true), Some(last_object)) = (list.is_truncated, objects.last()) {
|
||||
next_marker.clone_from(&last_object.key);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("found {} published extensions", published_versions.len());
|
||||
|
||||
let known_versions = app_state.db.get_known_extension_versions().await?;
|
||||
|
||||
let mut new_versions = HashMap::<&str, Vec<NewExtensionVersion>>::default();
|
||||
let empty = Vec::new();
|
||||
for (extension_id, published_versions) in &published_versions {
|
||||
let known_versions = known_versions.get(extension_id).unwrap_or(&empty);
|
||||
|
||||
for published_version in published_versions {
|
||||
if known_versions
|
||||
.binary_search_by_key(&published_version, |known_version| known_version)
|
||||
.is_err()
|
||||
&& let Some(extension) = fetch_extension_manifest(
|
||||
blob_store_client,
|
||||
blob_store_bucket,
|
||||
extension_id,
|
||||
published_version,
|
||||
)
|
||||
.await
|
||||
.log_err()
|
||||
{
|
||||
new_versions
|
||||
.entry(extension_id)
|
||||
.or_default()
|
||||
.push(extension);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app_state
|
||||
.db
|
||||
.insert_extension_versions(&new_versions)
|
||||
.await?;
|
||||
|
||||
log::info!(
|
||||
"fetched {} new extensions from blob store",
|
||||
new_versions.values().map(|v| v.len()).sum::<usize>()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_extension_manifest(
|
||||
blob_store_client: &aws_sdk_s3::Client,
|
||||
blob_store_bucket: &String,
|
||||
extension_id: &str,
|
||||
version: &str,
|
||||
) -> anyhow::Result<NewExtensionVersion> {
|
||||
let object = blob_store_client
|
||||
.get_object()
|
||||
.bucket(blob_store_bucket)
|
||||
.key(format!("extensions/{extension_id}/{version}/manifest.json"))
|
||||
.send()
|
||||
.await?;
|
||||
let manifest_bytes = object
|
||||
.body
|
||||
.collect()
|
||||
.await
|
||||
.map(|data| data.into_bytes())
|
||||
.with_context(|| {
|
||||
format!("failed to download manifest for extension {extension_id} version {version}")
|
||||
})?
|
||||
.to_vec();
|
||||
let manifest =
|
||||
serde_json::from_slice::<ExtensionApiManifest>(&manifest_bytes).with_context(|| {
|
||||
format!(
|
||||
"invalid manifest for extension {extension_id} version {version}: {}",
|
||||
String::from_utf8_lossy(&manifest_bytes)
|
||||
)
|
||||
})?;
|
||||
let published_at = object.last_modified.with_context(|| {
|
||||
format!("missing last modified timestamp for extension {extension_id} version {version}")
|
||||
})?;
|
||||
let published_at = time::OffsetDateTime::from_unix_timestamp_nanos(published_at.as_nanos())?;
|
||||
let published_at = PrimitiveDateTime::new(published_at.date(), published_at.time());
|
||||
let version = semver::Version::parse(&manifest.version).with_context(|| {
|
||||
format!("invalid version for extension {extension_id} version {version}")
|
||||
})?;
|
||||
Ok(NewExtensionVersion {
|
||||
name: manifest.name,
|
||||
version,
|
||||
description: manifest.description.unwrap_or_default(),
|
||||
authors: manifest.authors,
|
||||
repository: manifest.repository,
|
||||
schema_version: manifest.schema_version.unwrap_or(0),
|
||||
wasm_api_version: manifest.wasm_api_version,
|
||||
provides: manifest.provides,
|
||||
published_at,
|
||||
})
|
||||
}
|
||||
86
crates/collab/src/auth.rs
Normal file
86
crates/collab/src/auth.rs
Normal file
@@ -0,0 +1,86 @@
|
||||
use crate::entities::User;
|
||||
use crate::{AppState, Error, db::UserId, rpc::Principal};
|
||||
use anyhow::Context as _;
|
||||
use axum::{
|
||||
http::{self, Request, StatusCode},
|
||||
middleware::Next,
|
||||
response::IntoResponse,
|
||||
};
|
||||
use cloud_api_types::GetAuthenticatedUserResponse;
|
||||
pub use rpc::auth::random_token;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Validates the authorization header and adds an Extension<Principal> to the request.
|
||||
/// Authorization: <user-id> <token>
|
||||
/// <token> is the access_token attached to that user.
|
||||
/// Authorization: "dev-server-token" <token>
|
||||
pub async fn validate_header<B>(mut req: Request<B>, next: Next<B>) -> impl IntoResponse {
|
||||
let mut auth_header = req
|
||||
.headers()
|
||||
.get(http::header::AUTHORIZATION)
|
||||
.and_then(|header| header.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
Error::http(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"missing authorization header".to_string(),
|
||||
)
|
||||
})?
|
||||
.split_whitespace();
|
||||
|
||||
let state = req.extensions().get::<Arc<AppState>>().unwrap();
|
||||
|
||||
let first = auth_header.next().unwrap_or("");
|
||||
if first == "dev-server-token" {
|
||||
Err(Error::http(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"Dev servers were removed in Zed 0.157 please upgrade to SSH remoting".to_string(),
|
||||
))?;
|
||||
}
|
||||
|
||||
let user_id = UserId(first.parse().map_err(|_| {
|
||||
Error::http(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"missing user id in authorization header".to_string(),
|
||||
)
|
||||
})?);
|
||||
|
||||
let access_token = auth_header.next().ok_or_else(|| {
|
||||
Error::http(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"missing access token in authorization header".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let http_client = state.http_client.clone().expect("no HTTP client");
|
||||
|
||||
let response = http_client
|
||||
.get(format!("{}/client/users/me", state.config.zed_cloud_url()))
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Authorization", format!("{user_id} {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.context("failed to validate access token")?;
|
||||
if let Ok(response) = response.error_for_status() {
|
||||
let response_body: GetAuthenticatedUserResponse = response
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse response body")?;
|
||||
|
||||
let user = User {
|
||||
id: UserId(response_body.user.id),
|
||||
github_login: response_body.user.github_login,
|
||||
avatar_url: response_body.user.avatar_url,
|
||||
name: response_body.user.name,
|
||||
admin: response_body.user.is_staff,
|
||||
connected_once: response_body.user.has_connected_to_collab_once,
|
||||
};
|
||||
|
||||
req.extensions_mut().insert(Principal::User(user));
|
||||
return Ok::<_, Error>(next.run(req).await);
|
||||
}
|
||||
|
||||
Err(Error::http(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"invalid credentials".to_string(),
|
||||
))
|
||||
}
|
||||
12
crates/collab/src/bin/dotenv.rs
Normal file
12
crates/collab/src/bin/dotenv.rs
Normal file
@@ -0,0 +1,12 @@
|
||||
use collab::env::get_dotenv_vars;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
for (key, value) in get_dotenv_vars(".")? {
|
||||
if option_env!("POWERSHELL").is_some() {
|
||||
println!("$env:{}=\"{}\"", key, value);
|
||||
} else {
|
||||
println!("export {}=\"{}\"", key, value);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
2
crates/collab/src/completion.rs
Normal file
2
crates/collab/src/completion.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
use anyhow::{Result, anyhow};
|
||||
use rpc::proto;
|
||||
777
crates/collab/src/db.rs
Normal file
777
crates/collab/src/db.rs
Normal file
@@ -0,0 +1,777 @@
|
||||
mod ids;
|
||||
pub mod queries;
|
||||
mod tables;
|
||||
|
||||
use crate::{Error, Result};
|
||||
use anyhow::{Context as _, anyhow};
|
||||
use cloud_api_types::{ExtensionMetadata, ExtensionProvides};
|
||||
use collections::{BTreeMap, BTreeSet, HashMap, HashSet};
|
||||
use dashmap::DashMap;
|
||||
use futures::StreamExt;
|
||||
use project_repository_statuses::StatusKind;
|
||||
use rpc::{
|
||||
ConnectionId,
|
||||
proto::{self},
|
||||
};
|
||||
use sea_orm::{
|
||||
ActiveValue, Condition, ConnectionTrait, DatabaseConnection, DatabaseTransaction,
|
||||
FromQueryResult, IntoActiveModel, IsolationLevel, JoinType, QueryOrder, QuerySelect, Statement,
|
||||
TransactionTrait,
|
||||
entity::prelude::*,
|
||||
sea_query::{Alias, Expr, OnConflict},
|
||||
};
|
||||
use semver::Version;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::ops::RangeInclusive;
|
||||
use std::{
|
||||
future::Future,
|
||||
marker::PhantomData,
|
||||
ops::{Deref, DerefMut},
|
||||
rc::Rc,
|
||||
sync::Arc,
|
||||
};
|
||||
use time::PrimitiveDateTime;
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard};
|
||||
use util::paths::PathStyle;
|
||||
use worktree_settings_file::LocalSettingsKind;
|
||||
|
||||
pub use ids::*;
|
||||
pub use sea_orm::ConnectOptions;
|
||||
pub use tables::*;
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
pub struct DatabaseTestOptions {
|
||||
pub executor: gpui::BackgroundExecutor,
|
||||
pub runtime: tokio::runtime::Runtime,
|
||||
pub query_failure_probability: parking_lot::Mutex<f64>,
|
||||
}
|
||||
|
||||
/// Database gives you a handle that lets you access the database.
|
||||
/// It handles pooling internally.
|
||||
pub struct Database {
|
||||
pub options: ConnectOptions,
|
||||
pub pool: DatabaseConnection,
|
||||
rooms: DashMap<RoomId, Arc<Mutex<()>>>,
|
||||
projects: DashMap<ProjectId, Arc<Mutex<()>>>,
|
||||
notification_kinds_by_id: HashMap<NotificationKindId, &'static str>,
|
||||
notification_kinds_by_name: HashMap<String, NotificationKindId>,
|
||||
#[cfg(feature = "test-support")]
|
||||
pub test_options: Option<DatabaseTestOptions>,
|
||||
}
|
||||
|
||||
// The `Database` type has so many methods that its impl blocks are split into
|
||||
// separate files in the `queries` folder.
|
||||
impl Database {
|
||||
/// Connects to the database with the given options
|
||||
pub async fn new(options: ConnectOptions) -> Result<Self> {
|
||||
sqlx::any::install_default_drivers();
|
||||
Ok(Self {
|
||||
options: options.clone(),
|
||||
pool: sea_orm::Database::connect(options).await?,
|
||||
rooms: DashMap::with_capacity(16384),
|
||||
projects: DashMap::with_capacity(16384),
|
||||
notification_kinds_by_id: HashMap::default(),
|
||||
notification_kinds_by_name: HashMap::default(),
|
||||
#[cfg(feature = "test-support")]
|
||||
test_options: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn options(&self) -> &ConnectOptions {
|
||||
&self.options
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn reset(&self) {
|
||||
self.rooms.clear();
|
||||
self.projects.clear();
|
||||
}
|
||||
|
||||
pub async fn transaction<F, Fut, T>(&self, f: F) -> Result<T>
|
||||
where
|
||||
F: Send + Fn(TransactionHandle) -> Fut,
|
||||
Fut: Send + Future<Output = Result<T>>,
|
||||
{
|
||||
let body = async {
|
||||
let (tx, result) = self.with_transaction(&f).await?;
|
||||
match result {
|
||||
Ok(result) => match tx.commit().await.map_err(Into::into) {
|
||||
Ok(()) => Ok(result),
|
||||
Err(error) => Err(error),
|
||||
},
|
||||
Err(error) => {
|
||||
tx.rollback().await?;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.run(body).await
|
||||
}
|
||||
|
||||
/// The same as room_transaction, but if you need to only optionally return a Room.
|
||||
async fn optional_room_transaction<F, Fut, T>(
|
||||
&self,
|
||||
f: F,
|
||||
) -> Result<Option<TransactionGuard<T>>>
|
||||
where
|
||||
F: Send + Fn(TransactionHandle) -> Fut,
|
||||
Fut: Send + Future<Output = Result<Option<(RoomId, T)>>>,
|
||||
{
|
||||
let body = async {
|
||||
let (tx, result) = self.with_transaction(&f).await?;
|
||||
match result {
|
||||
Ok(Some((room_id, data))) => {
|
||||
let lock = self.rooms.entry(room_id).or_default().clone();
|
||||
let _guard = lock.lock_owned().await;
|
||||
match tx.commit().await.map_err(Into::into) {
|
||||
Ok(()) => Ok(Some(TransactionGuard {
|
||||
data,
|
||||
_guard,
|
||||
_not_send: PhantomData,
|
||||
})),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
Ok(None) => match tx.commit().await.map_err(Into::into) {
|
||||
Ok(()) => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
},
|
||||
Err(error) => {
|
||||
tx.rollback().await?;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.run(body).await
|
||||
}
|
||||
|
||||
async fn project_transaction<F, Fut, T>(
|
||||
&self,
|
||||
project_id: ProjectId,
|
||||
f: F,
|
||||
) -> Result<TransactionGuard<T>>
|
||||
where
|
||||
F: Send + Fn(TransactionHandle) -> Fut,
|
||||
Fut: Send + Future<Output = Result<T>>,
|
||||
{
|
||||
let room_id = Database::room_id_for_project(self, project_id).await?;
|
||||
let body = async {
|
||||
let lock = if let Some(room_id) = room_id {
|
||||
self.rooms.entry(room_id).or_default().clone()
|
||||
} else {
|
||||
self.projects.entry(project_id).or_default().clone()
|
||||
};
|
||||
let _guard = lock.lock_owned().await;
|
||||
let (tx, result) = self.with_transaction(&f).await?;
|
||||
match result {
|
||||
Ok(data) => match tx.commit().await.map_err(Into::into) {
|
||||
Ok(()) => Ok(TransactionGuard {
|
||||
data,
|
||||
_guard,
|
||||
_not_send: PhantomData,
|
||||
}),
|
||||
Err(error) => Err(error),
|
||||
},
|
||||
Err(error) => {
|
||||
tx.rollback().await?;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.run(body).await
|
||||
}
|
||||
|
||||
/// room_transaction runs the block in a transaction. It returns a RoomGuard, that keeps
|
||||
/// the database locked until it is dropped. This ensures that updates sent to clients are
|
||||
/// properly serialized with respect to database changes.
|
||||
async fn room_transaction<F, Fut, T>(
|
||||
&self,
|
||||
room_id: RoomId,
|
||||
f: F,
|
||||
) -> Result<TransactionGuard<T>>
|
||||
where
|
||||
F: Send + Fn(TransactionHandle) -> Fut,
|
||||
Fut: Send + Future<Output = Result<T>>,
|
||||
{
|
||||
let body = async {
|
||||
let lock = self.rooms.entry(room_id).or_default().clone();
|
||||
let _guard = lock.lock_owned().await;
|
||||
let (tx, result) = self.with_transaction(&f).await?;
|
||||
match result {
|
||||
Ok(data) => match tx.commit().await.map_err(Into::into) {
|
||||
Ok(()) => Ok(TransactionGuard {
|
||||
data,
|
||||
_guard,
|
||||
_not_send: PhantomData,
|
||||
}),
|
||||
Err(error) => Err(error),
|
||||
},
|
||||
Err(error) => {
|
||||
tx.rollback().await?;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
self.run(body).await
|
||||
}
|
||||
|
||||
async fn with_transaction<F, Fut, T>(&self, f: &F) -> Result<(DatabaseTransaction, Result<T>)>
|
||||
where
|
||||
F: Send + Fn(TransactionHandle) -> Fut,
|
||||
Fut: Send + Future<Output = Result<T>>,
|
||||
{
|
||||
let tx = self
|
||||
.pool
|
||||
.begin_with_config(Some(IsolationLevel::ReadCommitted), None)
|
||||
.await?;
|
||||
|
||||
let mut tx = Arc::new(Some(tx));
|
||||
let result = f(TransactionHandle(tx.clone())).await;
|
||||
let tx = Arc::get_mut(&mut tx)
|
||||
.and_then(|tx| tx.take())
|
||||
.context("couldn't complete transaction because it's still in use")?;
|
||||
|
||||
Ok((tx, result))
|
||||
}
|
||||
|
||||
async fn run<F, T>(&self, future: F) -> Result<T>
|
||||
where
|
||||
F: Future<Output = Result<T>>,
|
||||
{
|
||||
#[cfg(feature = "test-support")]
|
||||
{
|
||||
let test_options = self.test_options.as_ref().unwrap();
|
||||
test_options.executor.simulate_random_delay().await;
|
||||
let fail_probability = *test_options.query_failure_probability.lock();
|
||||
if test_options.executor.rng().random_bool(fail_probability) {
|
||||
return Err(anyhow!("simulated query failure"))?;
|
||||
}
|
||||
|
||||
test_options.runtime.block_on(future)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "test-support"))]
|
||||
{
|
||||
future.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A handle to a [`DatabaseTransaction`].
|
||||
pub struct TransactionHandle(pub(crate) Arc<Option<DatabaseTransaction>>);
|
||||
|
||||
impl Deref for TransactionHandle {
|
||||
type Target = DatabaseTransaction;
|
||||
|
||||
fn deref(&self) -> &Self::Target {
|
||||
self.0.as_ref().as_ref().unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// [`TransactionGuard`] keeps a database transaction alive until it is dropped.
|
||||
/// It wraps data that depends on the state of the database and prevents an additional
|
||||
/// transaction from starting that would invalidate that data.
|
||||
pub struct TransactionGuard<T> {
|
||||
data: T,
|
||||
_guard: OwnedMutexGuard<()>,
|
||||
_not_send: PhantomData<Rc<()>>,
|
||||
}
|
||||
|
||||
impl<T> Deref for TransactionGuard<T> {
|
||||
type Target = T;
|
||||
|
||||
fn deref(&self) -> &T {
|
||||
&self.data
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> DerefMut for TransactionGuard<T> {
|
||||
fn deref_mut(&mut self) -> &mut T {
|
||||
&mut self.data
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> TransactionGuard<T> {
|
||||
/// Returns the inner value of the guard.
|
||||
pub fn into_inner(self) -> T {
|
||||
self.data
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Contact {
|
||||
Accepted { user_id: UserId, busy: bool },
|
||||
Outgoing { user_id: UserId },
|
||||
Incoming { user_id: UserId },
|
||||
}
|
||||
|
||||
impl Contact {
|
||||
pub fn user_id(&self) -> UserId {
|
||||
match self {
|
||||
Contact::Accepted { user_id, .. } => *user_id,
|
||||
Contact::Outgoing { user_id } => *user_id,
|
||||
Contact::Incoming { user_id, .. } => *user_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type NotificationBatch = Vec<(UserId, proto::Notification)>;
|
||||
|
||||
pub struct CreatedChannelMessage {
|
||||
pub message_id: MessageId,
|
||||
pub participant_connection_ids: HashSet<ConnectionId>,
|
||||
pub notifications: NotificationBatch,
|
||||
}
|
||||
|
||||
pub struct UpdatedChannelMessage {
|
||||
pub message_id: MessageId,
|
||||
pub participant_connection_ids: Vec<ConnectionId>,
|
||||
pub notifications: NotificationBatch,
|
||||
pub reply_to_message_id: Option<MessageId>,
|
||||
pub timestamp: PrimitiveDateTime,
|
||||
pub deleted_mention_notification_ids: Vec<NotificationId>,
|
||||
pub updated_mention_notifications: Vec<rpc::proto::Notification>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, FromQueryResult, Serialize, Deserialize)]
|
||||
pub struct Invite {
|
||||
pub email_address: String,
|
||||
pub email_confirmation_code: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize)]
|
||||
pub struct NewSignup {
|
||||
pub email_address: String,
|
||||
pub platform_mac: bool,
|
||||
pub platform_windows: bool,
|
||||
pub platform_linux: bool,
|
||||
pub editor_features: Vec<String>,
|
||||
pub programming_languages: Vec<String>,
|
||||
pub device_id: Option<String>,
|
||||
pub added_to_mailing_list: bool,
|
||||
pub created_at: Option<DateTime>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Deserialize, Serialize, FromQueryResult)]
|
||||
pub struct WaitlistSummary {
|
||||
pub count: i64,
|
||||
pub linux_count: i64,
|
||||
pub mac_count: i64,
|
||||
pub windows_count: i64,
|
||||
pub unknown_count: i64,
|
||||
}
|
||||
|
||||
/// The parameters to create a new user.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct NewUserParams {
|
||||
pub github_login: String,
|
||||
pub github_user_id: i32,
|
||||
}
|
||||
|
||||
/// The result of creating a new user.
|
||||
#[derive(Debug)]
|
||||
pub struct NewUserResult {
|
||||
pub user_id: UserId,
|
||||
}
|
||||
|
||||
/// The result of updating a channel membership.
|
||||
#[derive(Debug)]
|
||||
pub struct MembershipUpdated {
|
||||
pub channel_id: ChannelId,
|
||||
pub new_channels: ChannelsForUser,
|
||||
pub removed_channels: Vec<ChannelId>,
|
||||
}
|
||||
|
||||
/// The result of setting a member's role.
|
||||
#[derive(Debug)]
|
||||
|
||||
pub enum SetMemberRoleResult {
|
||||
InviteUpdated(Channel),
|
||||
MembershipUpdated(MembershipUpdated),
|
||||
}
|
||||
|
||||
/// The result of inviting a member to a channel.
|
||||
#[derive(Debug)]
|
||||
pub struct InviteMemberResult {
|
||||
pub channel: Channel,
|
||||
pub notifications: NotificationBatch,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RespondToChannelInvite {
|
||||
pub membership_update: Option<MembershipUpdated>,
|
||||
pub notifications: NotificationBatch,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RemoveChannelMemberResult {
|
||||
pub membership_update: MembershipUpdated,
|
||||
pub notification_id: Option<NotificationId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
pub struct Channel {
|
||||
pub id: ChannelId,
|
||||
pub name: String,
|
||||
pub visibility: ChannelVisibility,
|
||||
/// parent_path is the channel ids from the root to this one (not including this one)
|
||||
pub parent_path: Vec<ChannelId>,
|
||||
pub channel_order: i32,
|
||||
}
|
||||
|
||||
impl Channel {
|
||||
pub fn from_model(value: channel::Model) -> Self {
|
||||
Channel {
|
||||
id: value.id,
|
||||
visibility: value.visibility,
|
||||
name: value.clone().name,
|
||||
parent_path: value.ancestors().collect(),
|
||||
channel_order: value.channel_order,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_proto(&self) -> proto::Channel {
|
||||
proto::Channel {
|
||||
id: self.id.to_proto(),
|
||||
name: self.name.clone(),
|
||||
visibility: self.visibility.into(),
|
||||
parent_path: self.parent_path.iter().map(|c| c.to_proto()).collect(),
|
||||
channel_order: self.channel_order,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn root_id(&self) -> ChannelId {
|
||||
self.parent_path.first().copied().unwrap_or(self.id)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Eq, Hash)]
|
||||
pub struct ChannelMember {
|
||||
pub role: ChannelRole,
|
||||
pub user_id: UserId,
|
||||
pub kind: proto::channel_member::Kind,
|
||||
}
|
||||
|
||||
impl ChannelMember {
|
||||
pub fn to_proto(&self) -> proto::ChannelMember {
|
||||
proto::ChannelMember {
|
||||
role: self.role.into(),
|
||||
user_id: self.user_id.to_proto(),
|
||||
kind: self.kind.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub struct ChannelsForUser {
|
||||
pub channels: Vec<Channel>,
|
||||
pub channel_memberships: Vec<channel_member::Model>,
|
||||
pub channel_participants: HashMap<ChannelId, Vec<UserId>>,
|
||||
pub invited_channels: Vec<Channel>,
|
||||
|
||||
pub observed_buffer_versions: Vec<proto::ChannelBufferVersion>,
|
||||
pub latest_buffer_versions: Vec<proto::ChannelBufferVersion>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RejoinedChannelBuffer {
|
||||
pub buffer: proto::RejoinedChannelBuffer,
|
||||
pub old_connection_id: ConnectionId,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct JoinRoom {
|
||||
pub room: proto::Room,
|
||||
pub channel: Option<channel::Model>,
|
||||
}
|
||||
|
||||
pub struct RejoinedRoom {
|
||||
pub room: proto::Room,
|
||||
pub rejoined_projects: Vec<RejoinedProject>,
|
||||
pub reshared_projects: Vec<ResharedProject>,
|
||||
pub channel: Option<channel::Model>,
|
||||
}
|
||||
|
||||
pub struct ResharedProject {
|
||||
pub id: ProjectId,
|
||||
pub old_connection_id: ConnectionId,
|
||||
pub collaborators: Vec<ProjectCollaborator>,
|
||||
pub worktrees: Vec<proto::WorktreeMetadata>,
|
||||
}
|
||||
|
||||
pub struct RejoinedProject {
|
||||
pub id: ProjectId,
|
||||
pub old_connection_id: ConnectionId,
|
||||
pub collaborators: Vec<ProjectCollaborator>,
|
||||
pub worktrees: Vec<RejoinedWorktree>,
|
||||
pub updated_repositories: Vec<proto::UpdateRepository>,
|
||||
pub removed_repositories: Vec<u64>,
|
||||
pub language_servers: Vec<LanguageServer>,
|
||||
}
|
||||
|
||||
impl RejoinedProject {
|
||||
pub fn to_proto(&self) -> proto::RejoinedProject {
|
||||
let (language_servers, language_server_capabilities) = self
|
||||
.language_servers
|
||||
.clone()
|
||||
.into_iter()
|
||||
.map(|server| (server.server, server.capabilities))
|
||||
.unzip();
|
||||
proto::RejoinedProject {
|
||||
id: self.id.to_proto(),
|
||||
worktrees: self
|
||||
.worktrees
|
||||
.iter()
|
||||
.map(|worktree| proto::WorktreeMetadata {
|
||||
id: worktree.id,
|
||||
root_name: worktree.root_name.clone(),
|
||||
visible: worktree.visible,
|
||||
abs_path: worktree.abs_path.clone(),
|
||||
root_repo_common_dir: None,
|
||||
})
|
||||
.collect(),
|
||||
collaborators: self
|
||||
.collaborators
|
||||
.iter()
|
||||
.map(|collaborator| collaborator.to_proto())
|
||||
.collect(),
|
||||
language_servers,
|
||||
language_server_capabilities,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct RejoinedWorktree {
|
||||
pub id: u64,
|
||||
pub abs_path: String,
|
||||
pub root_name: String,
|
||||
pub visible: bool,
|
||||
pub updated_entries: Vec<proto::Entry>,
|
||||
pub removed_entries: Vec<u64>,
|
||||
pub updated_repositories: Vec<proto::RepositoryEntry>,
|
||||
pub removed_repositories: Vec<u64>,
|
||||
pub diagnostic_summaries: Vec<proto::DiagnosticSummary>,
|
||||
pub settings_files: Vec<WorktreeSettingsFile>,
|
||||
pub scan_id: u64,
|
||||
pub completed_scan_id: u64,
|
||||
pub root_repo_common_dir: Option<String>,
|
||||
}
|
||||
|
||||
pub struct LeftRoom {
|
||||
pub room: proto::Room,
|
||||
pub channel: Option<channel::Model>,
|
||||
pub left_projects: HashMap<ProjectId, LeftProject>,
|
||||
pub canceled_calls_to_user_ids: Vec<UserId>,
|
||||
pub deleted: bool,
|
||||
}
|
||||
|
||||
pub struct RefreshedRoom {
|
||||
pub room: proto::Room,
|
||||
pub channel: Option<channel::Model>,
|
||||
pub stale_participant_user_ids: Vec<UserId>,
|
||||
pub canceled_calls_to_user_ids: Vec<UserId>,
|
||||
}
|
||||
|
||||
pub struct RefreshedChannelBuffer {
|
||||
pub connection_ids: Vec<ConnectionId>,
|
||||
pub collaborators: Vec<proto::Collaborator>,
|
||||
}
|
||||
|
||||
pub struct Project {
|
||||
pub id: ProjectId,
|
||||
pub role: ChannelRole,
|
||||
pub collaborators: Vec<ProjectCollaborator>,
|
||||
pub worktrees: BTreeMap<u64, Worktree>,
|
||||
pub repositories: Vec<proto::UpdateRepository>,
|
||||
pub language_servers: Vec<LanguageServer>,
|
||||
pub path_style: PathStyle,
|
||||
pub features: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct ProjectCollaborator {
|
||||
pub connection_id: ConnectionId,
|
||||
pub user_id: UserId,
|
||||
pub replica_id: ReplicaId,
|
||||
pub is_host: bool,
|
||||
pub committer_name: Option<String>,
|
||||
pub committer_email: Option<String>,
|
||||
}
|
||||
|
||||
impl ProjectCollaborator {
|
||||
pub fn to_proto(&self) -> proto::Collaborator {
|
||||
proto::Collaborator {
|
||||
peer_id: Some(self.connection_id.into()),
|
||||
replica_id: self.replica_id.0 as u32,
|
||||
user_id: self.user_id.to_proto(),
|
||||
is_host: self.is_host,
|
||||
committer_name: self.committer_name.clone(),
|
||||
committer_email: self.committer_email.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LanguageServer {
|
||||
pub server: proto::LanguageServer,
|
||||
pub capabilities: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LeftProject {
|
||||
pub id: ProjectId,
|
||||
pub should_unshare: bool,
|
||||
pub connection_ids: Vec<ConnectionId>,
|
||||
}
|
||||
|
||||
pub struct Worktree {
|
||||
pub id: u64,
|
||||
pub abs_path: String,
|
||||
pub root_name: String,
|
||||
pub visible: bool,
|
||||
pub entries: Vec<proto::Entry>,
|
||||
pub legacy_repository_entries: BTreeMap<u64, proto::RepositoryEntry>,
|
||||
pub diagnostic_summaries: Vec<proto::DiagnosticSummary>,
|
||||
pub settings_files: Vec<WorktreeSettingsFile>,
|
||||
pub scan_id: u64,
|
||||
pub completed_scan_id: u64,
|
||||
pub root_repo_common_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WorktreeSettingsFile {
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
pub kind: LocalSettingsKind,
|
||||
pub outside_worktree: bool,
|
||||
}
|
||||
|
||||
pub struct NewExtensionVersion {
|
||||
pub name: String,
|
||||
pub version: semver::Version,
|
||||
pub description: String,
|
||||
pub authors: Vec<String>,
|
||||
pub repository: String,
|
||||
pub schema_version: i32,
|
||||
pub wasm_api_version: Option<String>,
|
||||
pub provides: BTreeSet<ExtensionProvides>,
|
||||
pub published_at: PrimitiveDateTime,
|
||||
}
|
||||
|
||||
pub struct ExtensionVersionConstraints {
|
||||
pub schema_versions: RangeInclusive<i32>,
|
||||
pub wasm_api_versions: RangeInclusive<semver::Version>,
|
||||
}
|
||||
|
||||
impl LocalSettingsKind {
|
||||
pub fn from_proto(proto_kind: proto::LocalSettingsKind) -> Self {
|
||||
match proto_kind {
|
||||
proto::LocalSettingsKind::Settings => Self::Settings,
|
||||
proto::LocalSettingsKind::Tasks => Self::Tasks,
|
||||
proto::LocalSettingsKind::Editorconfig => Self::Editorconfig,
|
||||
proto::LocalSettingsKind::Debug => Self::Debug,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_proto(self) -> proto::LocalSettingsKind {
|
||||
match self {
|
||||
Self::Settings => proto::LocalSettingsKind::Settings,
|
||||
Self::Tasks => proto::LocalSettingsKind::Tasks,
|
||||
Self::Editorconfig => proto::LocalSettingsKind::Editorconfig,
|
||||
Self::Debug => proto::LocalSettingsKind::Debug,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn db_status_to_proto(
|
||||
entry: project_repository_statuses::Model,
|
||||
) -> anyhow::Result<proto::StatusEntry> {
|
||||
use proto::git_file_status::{Tracked, Unmerged, Variant};
|
||||
|
||||
let (simple_status, variant) =
|
||||
match (entry.status_kind, entry.first_status, entry.second_status) {
|
||||
(StatusKind::Untracked, None, None) => (
|
||||
proto::GitStatus::Added as i32,
|
||||
Variant::Untracked(Default::default()),
|
||||
),
|
||||
(StatusKind::Ignored, None, None) => (
|
||||
proto::GitStatus::Added as i32,
|
||||
Variant::Ignored(Default::default()),
|
||||
),
|
||||
(StatusKind::Unmerged, Some(first_head), Some(second_head)) => (
|
||||
proto::GitStatus::Conflict as i32,
|
||||
Variant::Unmerged(Unmerged {
|
||||
first_head,
|
||||
second_head,
|
||||
}),
|
||||
),
|
||||
(StatusKind::Tracked, Some(index_status), Some(worktree_status)) => {
|
||||
let simple_status = if worktree_status != proto::GitStatus::Unmodified as i32 {
|
||||
worktree_status
|
||||
} else if index_status != proto::GitStatus::Unmodified as i32 {
|
||||
index_status
|
||||
} else {
|
||||
proto::GitStatus::Unmodified as i32
|
||||
};
|
||||
(
|
||||
simple_status,
|
||||
Variant::Tracked(Tracked {
|
||||
index_status,
|
||||
worktree_status,
|
||||
}),
|
||||
)
|
||||
}
|
||||
_ => {
|
||||
anyhow::bail!("Unexpected combination of status fields: {entry:?}");
|
||||
}
|
||||
};
|
||||
Ok(proto::StatusEntry {
|
||||
repo_path: entry.repo_path,
|
||||
simple_status,
|
||||
status: Some(proto::GitFileStatus {
|
||||
variant: Some(variant),
|
||||
}),
|
||||
diff_stat_added: entry.lines_added.map(|v| v as u32),
|
||||
diff_stat_deleted: entry.lines_deleted.map(|v| v as u32),
|
||||
})
|
||||
}
|
||||
|
||||
fn proto_status_to_db(
|
||||
status_entry: proto::StatusEntry,
|
||||
) -> (String, StatusKind, Option<i32>, Option<i32>) {
|
||||
use proto::git_file_status::{Tracked, Unmerged, Variant};
|
||||
|
||||
let (status_kind, first_status, second_status) = status_entry
|
||||
.status
|
||||
.clone()
|
||||
.and_then(|status| status.variant)
|
||||
.map_or(
|
||||
(StatusKind::Untracked, None, None),
|
||||
|variant| match variant {
|
||||
Variant::Untracked(_) => (StatusKind::Untracked, None, None),
|
||||
Variant::Ignored(_) => (StatusKind::Ignored, None, None),
|
||||
Variant::Unmerged(Unmerged {
|
||||
first_head,
|
||||
second_head,
|
||||
}) => (StatusKind::Unmerged, Some(first_head), Some(second_head)),
|
||||
Variant::Tracked(Tracked {
|
||||
index_status,
|
||||
worktree_status,
|
||||
}) => (
|
||||
StatusKind::Tracked,
|
||||
Some(index_status),
|
||||
Some(worktree_status),
|
||||
),
|
||||
},
|
||||
);
|
||||
(
|
||||
status_entry.repo_path,
|
||||
status_kind,
|
||||
first_status,
|
||||
second_status,
|
||||
)
|
||||
}
|
||||
315
crates/collab/src/db/ids.rs
Normal file
315
crates/collab/src/db/ids.rs
Normal file
@@ -0,0 +1,315 @@
|
||||
use crate::Result;
|
||||
use rpc::proto;
|
||||
use sea_orm::{DbErr, entity::prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[macro_export]
|
||||
macro_rules! id_type {
|
||||
($name:ident) => {
|
||||
#[derive(
|
||||
Clone,
|
||||
Copy,
|
||||
Debug,
|
||||
Default,
|
||||
PartialEq,
|
||||
Eq,
|
||||
PartialOrd,
|
||||
Ord,
|
||||
Hash,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
DeriveValueType,
|
||||
)]
|
||||
#[allow(missing_docs)]
|
||||
#[serde(transparent)]
|
||||
pub struct $name(pub i32);
|
||||
|
||||
impl $name {
|
||||
#[allow(unused)]
|
||||
#[allow(missing_docs)]
|
||||
pub const MAX: Self = Self(i32::MAX);
|
||||
|
||||
#[allow(unused)]
|
||||
#[allow(missing_docs)]
|
||||
pub fn from_proto(value: u64) -> Self {
|
||||
debug_assert!(value != 0);
|
||||
Self(value as i32)
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[allow(missing_docs)]
|
||||
pub fn to_proto(self) -> u64 {
|
||||
self.0 as u64
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for $name {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
impl sea_orm::TryFromU64 for $name {
|
||||
fn try_from_u64(n: u64) -> Result<Self, DbErr> {
|
||||
Ok(Self(n.try_into().map_err(|_| {
|
||||
DbErr::ConvertFromU64(concat!(
|
||||
"error converting ",
|
||||
stringify!($name),
|
||||
" to u64"
|
||||
))
|
||||
})?))
|
||||
}
|
||||
}
|
||||
|
||||
impl sea_orm::sea_query::Nullable for $name {
|
||||
fn null() -> Value {
|
||||
Value::Int(None)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
id_type!(BufferId);
|
||||
id_type!(ChannelBufferCollaboratorId);
|
||||
id_type!(ChannelChatParticipantId);
|
||||
id_type!(ChannelId);
|
||||
id_type!(ChannelMemberId);
|
||||
id_type!(ContactId);
|
||||
id_type!(ExtensionId);
|
||||
id_type!(FlagId);
|
||||
id_type!(FollowerId);
|
||||
id_type!(HostedProjectId);
|
||||
id_type!(MessageId);
|
||||
id_type!(NotificationId);
|
||||
id_type!(NotificationKindId);
|
||||
id_type!(ProjectCollaboratorId);
|
||||
id_type!(ProjectId);
|
||||
id_type!(ReplicaId);
|
||||
id_type!(RoomId);
|
||||
id_type!(RoomParticipantId);
|
||||
id_type!(ServerId);
|
||||
id_type!(SignupId);
|
||||
id_type!(UserId);
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, DeriveValueType)]
|
||||
pub struct SharedThreadId(pub Uuid);
|
||||
|
||||
impl SharedThreadId {
|
||||
pub fn from_proto(id: String) -> Option<Self> {
|
||||
Uuid::parse_str(&id).ok().map(SharedThreadId)
|
||||
}
|
||||
|
||||
pub fn to_proto(self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl sea_orm::TryFromU64 for SharedThreadId {
|
||||
fn try_from_u64(_n: u64) -> std::result::Result<Self, DbErr> {
|
||||
Err(DbErr::ConvertFromU64(
|
||||
"SharedThreadId uses UUID and cannot be converted from u64",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl sea_orm::sea_query::Nullable for SharedThreadId {
|
||||
fn null() -> Value {
|
||||
Value::Uuid(None)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for SharedThreadId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
|
||||
self.0.fmt(f)
|
||||
}
|
||||
}
|
||||
|
||||
/// ChannelRole gives you permissions for both channels and calls.
|
||||
#[derive(
|
||||
Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash, Serialize,
|
||||
)]
|
||||
#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
|
||||
pub enum ChannelRole {
|
||||
/// Admin can read/write and change permissions.
|
||||
#[sea_orm(string_value = "admin")]
|
||||
Admin,
|
||||
/// Member can read/write, but not change permissions.
|
||||
#[sea_orm(string_value = "member")]
|
||||
#[default]
|
||||
Member,
|
||||
/// Talker can read, but not write.
|
||||
/// They can use microphones and the channel chat
|
||||
#[sea_orm(string_value = "talker")]
|
||||
Talker,
|
||||
/// Guest can read, but not write.
|
||||
/// They can not use microphones but can use the chat.
|
||||
#[sea_orm(string_value = "guest")]
|
||||
Guest,
|
||||
/// Banned may not read.
|
||||
#[sea_orm(string_value = "banned")]
|
||||
Banned,
|
||||
}
|
||||
|
||||
impl ChannelRole {
|
||||
/// Returns true if this role is more powerful than the other role.
|
||||
pub fn should_override(&self, other: Self) -> bool {
|
||||
use ChannelRole::*;
|
||||
match self {
|
||||
Admin => matches!(other, Member | Banned | Talker | Guest),
|
||||
Member => matches!(other, Banned | Talker | Guest),
|
||||
Talker => matches!(other, Guest),
|
||||
Banned => matches!(other, Guest),
|
||||
Guest => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the maximal role between the two
|
||||
pub fn max(&self, other: Self) -> Self {
|
||||
if self.should_override(other) {
|
||||
*self
|
||||
} else {
|
||||
other
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_see_channel(&self, visibility: ChannelVisibility) -> bool {
|
||||
use ChannelRole::*;
|
||||
match self {
|
||||
Admin | Member => true,
|
||||
Guest | Talker => visibility == ChannelVisibility::Public,
|
||||
Banned => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the role allows access to all descendant channels
|
||||
pub fn can_see_all_descendants(&self) -> bool {
|
||||
use ChannelRole::*;
|
||||
match self {
|
||||
Admin | Member => true,
|
||||
Guest | Talker | Banned => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the role only allows access to public descendant channels
|
||||
pub fn can_only_see_public_descendants(&self) -> bool {
|
||||
use ChannelRole::*;
|
||||
match self {
|
||||
Guest | Talker => true,
|
||||
Admin | Member | Banned => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the role can share screen/microphone/projects into rooms.
|
||||
pub fn can_use_microphone(&self) -> bool {
|
||||
use ChannelRole::*;
|
||||
match self {
|
||||
Admin | Member | Talker => true,
|
||||
Guest | Banned => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the role can edit shared projects.
|
||||
pub fn can_edit_projects(&self) -> bool {
|
||||
use ChannelRole::*;
|
||||
match self {
|
||||
Admin | Member => true,
|
||||
Talker | Guest | Banned => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// True if the role can read shared projects.
|
||||
pub fn can_read_projects(&self) -> bool {
|
||||
use ChannelRole::*;
|
||||
match self {
|
||||
Admin | Member | Guest | Talker => true,
|
||||
Banned => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn requires_cla(&self) -> bool {
|
||||
use ChannelRole::*;
|
||||
match self {
|
||||
Admin | Member => true,
|
||||
Banned | Guest | Talker => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<proto::ChannelRole> for ChannelRole {
|
||||
fn from(value: proto::ChannelRole) -> Self {
|
||||
match value {
|
||||
proto::ChannelRole::Admin => ChannelRole::Admin,
|
||||
proto::ChannelRole::Member => ChannelRole::Member,
|
||||
proto::ChannelRole::Talker => ChannelRole::Talker,
|
||||
proto::ChannelRole::Guest => ChannelRole::Guest,
|
||||
proto::ChannelRole::Banned => ChannelRole::Banned,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ChannelRole> for proto::ChannelRole {
|
||||
fn from(val: ChannelRole) -> Self {
|
||||
match val {
|
||||
ChannelRole::Admin => proto::ChannelRole::Admin,
|
||||
ChannelRole::Member => proto::ChannelRole::Member,
|
||||
ChannelRole::Talker => proto::ChannelRole::Talker,
|
||||
ChannelRole::Guest => proto::ChannelRole::Guest,
|
||||
ChannelRole::Banned => proto::ChannelRole::Banned,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ChannelRole> for i32 {
|
||||
fn from(val: ChannelRole) -> Self {
|
||||
let proto: proto::ChannelRole = val.into();
|
||||
proto.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// ChannelVisibility controls whether channels are public or private.
|
||||
#[derive(Eq, PartialEq, Copy, Clone, Debug, EnumIter, DeriveActiveEnum, Default, Hash)]
|
||||
#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
|
||||
pub enum ChannelVisibility {
|
||||
/// Public channels are visible to anyone with the link. People join with the Guest role by default.
|
||||
#[sea_orm(string_value = "public")]
|
||||
Public,
|
||||
/// Members channels are only visible to members of this channel or its parents.
|
||||
#[sea_orm(string_value = "members")]
|
||||
#[default]
|
||||
Members,
|
||||
}
|
||||
|
||||
impl From<proto::ChannelVisibility> for ChannelVisibility {
|
||||
fn from(value: proto::ChannelVisibility) -> Self {
|
||||
match value {
|
||||
proto::ChannelVisibility::Public => ChannelVisibility::Public,
|
||||
proto::ChannelVisibility::Members => ChannelVisibility::Members,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ChannelVisibility> for proto::ChannelVisibility {
|
||||
fn from(val: ChannelVisibility) -> Self {
|
||||
match val {
|
||||
ChannelVisibility::Public => proto::ChannelVisibility::Public,
|
||||
ChannelVisibility::Members => proto::ChannelVisibility::Members,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ChannelVisibility> for i32 {
|
||||
fn from(val: ChannelVisibility) -> Self {
|
||||
let proto: proto::ChannelVisibility = val.into();
|
||||
proto.into()
|
||||
}
|
||||
}
|
||||
|
||||
/// Indicate whether a [Buffer] has permissions to edit.
|
||||
#[derive(PartialEq, Clone, Copy, Debug)]
|
||||
pub enum Capability {
|
||||
/// The buffer is a mutable replica.
|
||||
ReadWrite,
|
||||
/// The buffer is a read-only replica.
|
||||
ReadOnly,
|
||||
}
|
||||
13
crates/collab/src/db/queries.rs
Normal file
13
crates/collab/src/db/queries.rs
Normal file
@@ -0,0 +1,13 @@
|
||||
use super::*;
|
||||
|
||||
pub mod buffers;
|
||||
pub mod channels;
|
||||
pub mod contacts;
|
||||
pub mod contributors;
|
||||
pub mod extensions;
|
||||
pub mod notifications;
|
||||
pub mod projects;
|
||||
pub mod rooms;
|
||||
pub mod servers;
|
||||
pub mod shared_threads;
|
||||
pub mod users;
|
||||
1049
crates/collab/src/db/queries/buffers.rs
Normal file
1049
crates/collab/src/db/queries/buffers.rs
Normal file
File diff suppressed because it is too large
Load Diff
1113
crates/collab/src/db/queries/channels.rs
Normal file
1113
crates/collab/src/db/queries/channels.rs
Normal file
File diff suppressed because it is too large
Load Diff
363
crates/collab/src/db/queries/contacts.rs
Normal file
363
crates/collab/src/db/queries/contacts.rs
Normal file
@@ -0,0 +1,363 @@
|
||||
use anyhow::Context as _;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl Database {
|
||||
/// Retrieves the contacts for the user with the given ID.
|
||||
pub async fn get_contacts(&self, user_id: UserId) -> Result<Vec<Contact>> {
|
||||
#[derive(Debug, FromQueryResult)]
|
||||
struct ContactWithUserBusyStatuses {
|
||||
user_id_a: UserId,
|
||||
user_id_b: UserId,
|
||||
a_to_b: bool,
|
||||
accepted: bool,
|
||||
user_a_busy: bool,
|
||||
user_b_busy: bool,
|
||||
}
|
||||
|
||||
self.transaction(|tx| async move {
|
||||
let user_a_participant = Alias::new("user_a_participant");
|
||||
let user_b_participant = Alias::new("user_b_participant");
|
||||
let mut db_contacts = contact::Entity::find()
|
||||
.column_as(
|
||||
Expr::col((user_a_participant.clone(), room_participant::Column::Id))
|
||||
.is_not_null(),
|
||||
"user_a_busy",
|
||||
)
|
||||
.column_as(
|
||||
Expr::col((user_b_participant.clone(), room_participant::Column::Id))
|
||||
.is_not_null(),
|
||||
"user_b_busy",
|
||||
)
|
||||
.filter(
|
||||
contact::Column::UserIdA
|
||||
.eq(user_id)
|
||||
.or(contact::Column::UserIdB.eq(user_id)),
|
||||
)
|
||||
.join_as(
|
||||
JoinType::LeftJoin,
|
||||
contact::Relation::UserARoomParticipant.def(),
|
||||
user_a_participant,
|
||||
)
|
||||
.join_as(
|
||||
JoinType::LeftJoin,
|
||||
contact::Relation::UserBRoomParticipant.def(),
|
||||
user_b_participant,
|
||||
)
|
||||
.into_model::<ContactWithUserBusyStatuses>()
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut contacts = Vec::new();
|
||||
while let Some(db_contact) = db_contacts.next().await {
|
||||
let db_contact = db_contact?;
|
||||
if db_contact.user_id_a == user_id {
|
||||
if db_contact.accepted {
|
||||
contacts.push(Contact::Accepted {
|
||||
user_id: db_contact.user_id_b,
|
||||
busy: db_contact.user_b_busy,
|
||||
});
|
||||
} else if db_contact.a_to_b {
|
||||
contacts.push(Contact::Outgoing {
|
||||
user_id: db_contact.user_id_b,
|
||||
})
|
||||
} else {
|
||||
contacts.push(Contact::Incoming {
|
||||
user_id: db_contact.user_id_b,
|
||||
});
|
||||
}
|
||||
} else if db_contact.accepted {
|
||||
contacts.push(Contact::Accepted {
|
||||
user_id: db_contact.user_id_a,
|
||||
busy: db_contact.user_a_busy,
|
||||
});
|
||||
} else if db_contact.a_to_b {
|
||||
contacts.push(Contact::Incoming {
|
||||
user_id: db_contact.user_id_a,
|
||||
});
|
||||
} else {
|
||||
contacts.push(Contact::Outgoing {
|
||||
user_id: db_contact.user_id_a,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
contacts.sort_unstable_by_key(|contact| contact.user_id());
|
||||
|
||||
Ok(contacts)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns whether the given user is a busy (on a call).
|
||||
pub async fn is_user_busy(&self, user_id: UserId) -> Result<bool> {
|
||||
self.transaction(|tx| async move {
|
||||
let participant = room_participant::Entity::find()
|
||||
.filter(room_participant::Column::UserId.eq(user_id))
|
||||
.one(&*tx)
|
||||
.await?;
|
||||
Ok(participant.is_some())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns whether the user with `user_id_1` has the user with `user_id_2` as a contact.
|
||||
///
|
||||
/// In order for this to return `true`, `user_id_2` must have an accepted invite from `user_id_1`.
|
||||
pub async fn has_contact(&self, user_id_1: UserId, user_id_2: UserId) -> Result<bool> {
|
||||
self.transaction(|tx| async move {
|
||||
let (id_a, id_b) = if user_id_1 < user_id_2 {
|
||||
(user_id_1, user_id_2)
|
||||
} else {
|
||||
(user_id_2, user_id_1)
|
||||
};
|
||||
|
||||
Ok(contact::Entity::find()
|
||||
.filter(
|
||||
contact::Column::UserIdA
|
||||
.eq(id_a)
|
||||
.and(contact::Column::UserIdB.eq(id_b))
|
||||
.and(contact::Column::Accepted.eq(true)),
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.is_some())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Invite the user with `receiver_id` to be a contact of the user with `sender_id`.
|
||||
pub async fn send_contact_request(
|
||||
&self,
|
||||
sender_id: UserId,
|
||||
receiver_id: UserId,
|
||||
) -> Result<NotificationBatch> {
|
||||
self.transaction(|tx| async move {
|
||||
let (id_a, id_b, a_to_b) = if sender_id < receiver_id {
|
||||
(sender_id, receiver_id, true)
|
||||
} else {
|
||||
(receiver_id, sender_id, false)
|
||||
};
|
||||
|
||||
let rows_affected = contact::Entity::insert(contact::ActiveModel {
|
||||
user_id_a: ActiveValue::set(id_a),
|
||||
user_id_b: ActiveValue::set(id_b),
|
||||
a_to_b: ActiveValue::set(a_to_b),
|
||||
accepted: ActiveValue::set(false),
|
||||
should_notify: ActiveValue::set(true),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([contact::Column::UserIdA, contact::Column::UserIdB])
|
||||
.values([
|
||||
(contact::Column::Accepted, true.into()),
|
||||
(contact::Column::ShouldNotify, false.into()),
|
||||
])
|
||||
.action_and_where(
|
||||
contact::Column::Accepted.eq(false).and(
|
||||
contact::Column::AToB
|
||||
.eq(a_to_b)
|
||||
.and(contact::Column::UserIdA.eq(id_b))
|
||||
.or(contact::Column::AToB
|
||||
.ne(a_to_b)
|
||||
.and(contact::Column::UserIdA.eq(id_a))),
|
||||
),
|
||||
)
|
||||
.to_owned(),
|
||||
)
|
||||
.exec_without_returning(&*tx)
|
||||
.await?;
|
||||
|
||||
if rows_affected == 0 {
|
||||
Err(anyhow!("contact already requested"))?;
|
||||
}
|
||||
|
||||
Ok(self
|
||||
.create_notification(
|
||||
receiver_id,
|
||||
rpc::Notification::ContactRequest {
|
||||
sender_id: sender_id.to_proto(),
|
||||
},
|
||||
true,
|
||||
&tx,
|
||||
)
|
||||
.await?
|
||||
.into_iter()
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns a bool indicating whether the removed contact had originally accepted or not
|
||||
///
|
||||
/// Deletes the contact identified by the requester and responder ids, and then returns
|
||||
/// whether the deleted contact had originally accepted or was a pending contact request.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `requester_id` - The user that initiates this request
|
||||
/// * `responder_id` - The user that will be removed
|
||||
pub async fn remove_contact(
|
||||
&self,
|
||||
requester_id: UserId,
|
||||
responder_id: UserId,
|
||||
) -> Result<(bool, Option<NotificationId>)> {
|
||||
self.transaction(|tx| async move {
|
||||
let (id_a, id_b) = if responder_id < requester_id {
|
||||
(responder_id, requester_id)
|
||||
} else {
|
||||
(requester_id, responder_id)
|
||||
};
|
||||
|
||||
let contact = contact::Entity::find()
|
||||
.filter(
|
||||
contact::Column::UserIdA
|
||||
.eq(id_a)
|
||||
.and(contact::Column::UserIdB.eq(id_b)),
|
||||
)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.context("no such contact")?;
|
||||
|
||||
contact::Entity::delete_by_id(contact.id).exec(&*tx).await?;
|
||||
|
||||
let mut deleted_notification_id = None;
|
||||
if !contact.accepted {
|
||||
deleted_notification_id = self
|
||||
.remove_notification(
|
||||
responder_id,
|
||||
rpc::Notification::ContactRequest {
|
||||
sender_id: requester_id.to_proto(),
|
||||
},
|
||||
&tx,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
Ok((contact.accepted, deleted_notification_id))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Dismisses a contact notification for the given user.
|
||||
pub async fn dismiss_contact_notification(
|
||||
&self,
|
||||
user_id: UserId,
|
||||
contact_user_id: UserId,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
let (id_a, id_b, a_to_b) = if user_id < contact_user_id {
|
||||
(user_id, contact_user_id, true)
|
||||
} else {
|
||||
(contact_user_id, user_id, false)
|
||||
};
|
||||
|
||||
let result = contact::Entity::update_many()
|
||||
.set(contact::ActiveModel {
|
||||
should_notify: ActiveValue::set(false),
|
||||
..Default::default()
|
||||
})
|
||||
.filter(
|
||||
contact::Column::UserIdA
|
||||
.eq(id_a)
|
||||
.and(contact::Column::UserIdB.eq(id_b))
|
||||
.and(
|
||||
contact::Column::AToB
|
||||
.eq(a_to_b)
|
||||
.and(contact::Column::Accepted.eq(true))
|
||||
.or(contact::Column::AToB
|
||||
.ne(a_to_b)
|
||||
.and(contact::Column::Accepted.eq(false))),
|
||||
),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
if result.rows_affected == 0 {
|
||||
Err(anyhow!("no such contact request"))?
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Accept or decline a contact request
|
||||
pub async fn respond_to_contact_request(
|
||||
&self,
|
||||
responder_id: UserId,
|
||||
requester_id: UserId,
|
||||
accept: bool,
|
||||
) -> Result<NotificationBatch> {
|
||||
self.transaction(|tx| async move {
|
||||
let (id_a, id_b, a_to_b) = if responder_id < requester_id {
|
||||
(responder_id, requester_id, false)
|
||||
} else {
|
||||
(requester_id, responder_id, true)
|
||||
};
|
||||
let rows_affected = if accept {
|
||||
let result = contact::Entity::update_many()
|
||||
.set(contact::ActiveModel {
|
||||
accepted: ActiveValue::set(true),
|
||||
should_notify: ActiveValue::set(true),
|
||||
..Default::default()
|
||||
})
|
||||
.filter(
|
||||
contact::Column::UserIdA
|
||||
.eq(id_a)
|
||||
.and(contact::Column::UserIdB.eq(id_b))
|
||||
.and(contact::Column::AToB.eq(a_to_b)),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
result.rows_affected
|
||||
} else {
|
||||
let result = contact::Entity::delete_many()
|
||||
.filter(
|
||||
contact::Column::UserIdA
|
||||
.eq(id_a)
|
||||
.and(contact::Column::UserIdB.eq(id_b))
|
||||
.and(contact::Column::AToB.eq(a_to_b))
|
||||
.and(contact::Column::Accepted.eq(false)),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
result.rows_affected
|
||||
};
|
||||
|
||||
if rows_affected == 0 {
|
||||
Err(anyhow!("no such contact request"))?
|
||||
}
|
||||
|
||||
let mut notifications = Vec::new();
|
||||
notifications.extend(
|
||||
self.mark_notification_as_read_with_response(
|
||||
responder_id,
|
||||
&rpc::Notification::ContactRequest {
|
||||
sender_id: requester_id.to_proto(),
|
||||
},
|
||||
accept,
|
||||
&tx,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
||||
if accept {
|
||||
notifications.extend(
|
||||
self.create_notification(
|
||||
requester_id,
|
||||
rpc::Notification::ContactRequestAccepted {
|
||||
responder_id: responder_id.to_proto(),
|
||||
},
|
||||
true,
|
||||
&tx,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(notifications)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
23
crates/collab/src/db/queries/contributors.rs
Normal file
23
crates/collab/src/db/queries/contributors.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use super::*;
|
||||
|
||||
impl Database {
|
||||
/// Records that a given user has signed the CLA.
|
||||
#[cfg(feature = "test-support")]
|
||||
pub async fn add_contributor(&self, user_id: UserId) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
contributor::Entity::insert(contributor::ActiveModel {
|
||||
user_id: ActiveValue::Set(user_id),
|
||||
signed_at: ActiveValue::NotSet,
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(contributor::Column::UserId)
|
||||
.do_nothing()
|
||||
.to_owned(),
|
||||
)
|
||||
.exec_without_returning(&*tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
1
crates/collab/src/db/queries/dev_server_projects.rs
Normal file
1
crates/collab/src/db/queries/dev_server_projects.rs
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
403
crates/collab/src/db/queries/extensions.rs
Normal file
403
crates/collab/src/db/queries/extensions.rs
Normal file
@@ -0,0 +1,403 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
use anyhow::Context;
|
||||
use chrono::Utc;
|
||||
use sea_orm::sea_query::IntoCondition;
|
||||
use util::ResultExt;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl Database {
|
||||
pub async fn get_extensions_by_ids(
|
||||
&self,
|
||||
ids: &[&str],
|
||||
constraints: Option<&ExtensionVersionConstraints>,
|
||||
) -> Result<Vec<ExtensionMetadata>> {
|
||||
self.transaction(|tx| async move {
|
||||
let extensions = extension::Entity::find()
|
||||
.filter(extension::Column::ExternalId.is_in(ids.iter().copied()))
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
let mut max_versions = self
|
||||
.get_latest_versions_for_extensions(&extensions, constraints, &tx)
|
||||
.await?;
|
||||
|
||||
Ok(extensions
|
||||
.into_iter()
|
||||
.filter_map(|extension| {
|
||||
let (version, _) = max_versions.remove(&extension.id)?;
|
||||
Some(metadata_from_extension_and_version(extension, version))
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_latest_versions_for_extensions(
|
||||
&self,
|
||||
extensions: &[extension::Model],
|
||||
constraints: Option<&ExtensionVersionConstraints>,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<HashMap<ExtensionId, (extension_version::Model, Version)>> {
|
||||
let mut versions = extension_version::Entity::find()
|
||||
.filter(
|
||||
extension_version::Column::ExtensionId
|
||||
.is_in(extensions.iter().map(|extension| extension.id)),
|
||||
)
|
||||
.stream(tx)
|
||||
.await?;
|
||||
|
||||
let mut max_versions =
|
||||
HashMap::<ExtensionId, (extension_version::Model, Version)>::default();
|
||||
while let Some(version) = versions.next().await {
|
||||
let version = version?;
|
||||
let Some(extension_version) = Version::from_str(&version.version).log_err() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some((_, max_extension_version)) = &max_versions.get(&version.extension_id)
|
||||
&& max_extension_version > &extension_version
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(constraints) = constraints {
|
||||
if !constraints
|
||||
.schema_versions
|
||||
.contains(&version.schema_version)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(wasm_api_version) = version.wasm_api_version.as_ref() {
|
||||
if let Some(version) = Version::from_str(wasm_api_version).log_err() {
|
||||
if !constraints.wasm_api_versions.contains(&version) {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
max_versions.insert(version.extension_id, (version, extension_version));
|
||||
}
|
||||
|
||||
Ok(max_versions)
|
||||
}
|
||||
|
||||
/// Returns all of the versions for the extension with the given ID.
|
||||
pub async fn get_extension_versions(
|
||||
&self,
|
||||
extension_id: &str,
|
||||
) -> Result<Vec<ExtensionMetadata>> {
|
||||
self.transaction(|tx| async move {
|
||||
let condition = extension::Column::ExternalId
|
||||
.eq(extension_id)
|
||||
.into_condition();
|
||||
|
||||
self.get_extensions_where(condition, None, &tx).await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn get_extensions_where(
|
||||
&self,
|
||||
condition: Condition,
|
||||
limit: Option<u64>,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<ExtensionMetadata>> {
|
||||
let extensions = extension::Entity::find()
|
||||
.inner_join(extension_version::Entity)
|
||||
.select_also(extension_version::Entity)
|
||||
.filter(condition)
|
||||
.order_by_desc(extension::Column::TotalDownloadCount)
|
||||
.order_by_asc(extension::Column::Name)
|
||||
.limit(limit)
|
||||
.all(tx)
|
||||
.await?;
|
||||
|
||||
Ok(extensions
|
||||
.into_iter()
|
||||
.filter_map(|(extension, version)| {
|
||||
Some(metadata_from_extension_and_version(extension, version?))
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn get_extension(
|
||||
&self,
|
||||
extension_id: &str,
|
||||
constraints: Option<&ExtensionVersionConstraints>,
|
||||
) -> Result<Option<ExtensionMetadata>> {
|
||||
self.transaction(|tx| async move {
|
||||
let extension = extension::Entity::find()
|
||||
.filter(extension::Column::ExternalId.eq(extension_id))
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.with_context(|| format!("no such extension: {extension_id}"))?;
|
||||
|
||||
let extensions = [extension];
|
||||
let mut versions = self
|
||||
.get_latest_versions_for_extensions(&extensions, constraints, &tx)
|
||||
.await?;
|
||||
let [extension] = extensions;
|
||||
|
||||
Ok(versions.remove(&extension.id).map(|(max_version, _)| {
|
||||
metadata_from_extension_and_version(extension, max_version)
|
||||
}))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_extension_version(
|
||||
&self,
|
||||
extension_id: &str,
|
||||
version: &str,
|
||||
) -> Result<Option<ExtensionMetadata>> {
|
||||
self.transaction(|tx| async move {
|
||||
let extension = extension::Entity::find()
|
||||
.filter(extension::Column::ExternalId.eq(extension_id))
|
||||
.filter(extension_version::Column::Version.eq(version))
|
||||
.inner_join(extension_version::Entity)
|
||||
.select_also(extension_version::Entity)
|
||||
.one(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(extension.and_then(|(extension, version)| {
|
||||
Some(metadata_from_extension_and_version(extension, version?))
|
||||
}))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_known_extension_versions(&self) -> Result<HashMap<String, Vec<String>>> {
|
||||
self.transaction(|tx| async move {
|
||||
let mut extension_external_ids_by_id = HashMap::default();
|
||||
|
||||
let mut rows = extension::Entity::find().stream(&*tx).await?;
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
extension_external_ids_by_id.insert(row.id, row.external_id);
|
||||
}
|
||||
drop(rows);
|
||||
|
||||
let mut known_versions_by_extension_id: HashMap<String, Vec<String>> =
|
||||
HashMap::default();
|
||||
let mut rows = extension_version::Entity::find().stream(&*tx).await?;
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
|
||||
let Some(extension_id) = extension_external_ids_by_id.get(&row.extension_id) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let versions = known_versions_by_extension_id
|
||||
.entry(extension_id.clone())
|
||||
.or_default();
|
||||
if let Err(ix) = versions.binary_search(&row.version) {
|
||||
versions.insert(ix, row.version);
|
||||
}
|
||||
}
|
||||
drop(rows);
|
||||
|
||||
Ok(known_versions_by_extension_id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn insert_extension_versions(
|
||||
&self,
|
||||
versions_by_extension_id: &HashMap<&str, Vec<NewExtensionVersion>>,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
for (external_id, versions) in versions_by_extension_id {
|
||||
if versions.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let latest_version = versions
|
||||
.iter()
|
||||
.max_by_key(|version| &version.version)
|
||||
.unwrap();
|
||||
|
||||
let insert = extension::Entity::insert(extension::ActiveModel {
|
||||
name: ActiveValue::Set(latest_version.name.clone()),
|
||||
external_id: ActiveValue::Set((*external_id).to_owned()),
|
||||
id: ActiveValue::NotSet,
|
||||
latest_version: ActiveValue::Set(latest_version.version.to_string()),
|
||||
total_download_count: ActiveValue::NotSet,
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::columns([extension::Column::ExternalId])
|
||||
.update_column(extension::Column::ExternalId)
|
||||
.to_owned(),
|
||||
);
|
||||
|
||||
let extension = if tx.support_returning() {
|
||||
insert.exec_with_returning(&*tx).await?
|
||||
} else {
|
||||
// Sqlite
|
||||
insert.exec_without_returning(&*tx).await?;
|
||||
extension::Entity::find()
|
||||
.filter(extension::Column::ExternalId.eq(*external_id))
|
||||
.one(&*tx)
|
||||
.await?
|
||||
.context("failed to insert extension")?
|
||||
};
|
||||
|
||||
extension_version::Entity::insert_many(versions.iter().map(|version| {
|
||||
extension_version::ActiveModel {
|
||||
extension_id: ActiveValue::Set(extension.id),
|
||||
published_at: ActiveValue::Set(version.published_at),
|
||||
version: ActiveValue::Set(version.version.to_string()),
|
||||
authors: ActiveValue::Set(version.authors.join(", ")),
|
||||
repository: ActiveValue::Set(version.repository.clone()),
|
||||
description: ActiveValue::Set(version.description.clone()),
|
||||
schema_version: ActiveValue::Set(version.schema_version),
|
||||
wasm_api_version: ActiveValue::Set(version.wasm_api_version.clone()),
|
||||
provides_themes: ActiveValue::Set(
|
||||
version.provides.contains(&ExtensionProvides::Themes),
|
||||
),
|
||||
provides_icon_themes: ActiveValue::Set(
|
||||
version.provides.contains(&ExtensionProvides::IconThemes),
|
||||
),
|
||||
provides_languages: ActiveValue::Set(
|
||||
version.provides.contains(&ExtensionProvides::Languages),
|
||||
),
|
||||
provides_grammars: ActiveValue::Set(
|
||||
version.provides.contains(&ExtensionProvides::Grammars),
|
||||
),
|
||||
provides_language_servers: ActiveValue::Set(
|
||||
version
|
||||
.provides
|
||||
.contains(&ExtensionProvides::LanguageServers),
|
||||
),
|
||||
provides_context_servers: ActiveValue::Set(
|
||||
version
|
||||
.provides
|
||||
.contains(&ExtensionProvides::ContextServers),
|
||||
),
|
||||
provides_agent_servers: ActiveValue::Set(
|
||||
version.provides.contains(&ExtensionProvides::AgentServers),
|
||||
),
|
||||
provides_slash_commands: ActiveValue::Set(
|
||||
version.provides.contains(&ExtensionProvides::SlashCommands),
|
||||
),
|
||||
provides_indexed_docs_providers: ActiveValue::Set(
|
||||
version
|
||||
.provides
|
||||
.contains(&ExtensionProvides::IndexedDocsProviders),
|
||||
),
|
||||
provides_snippets: ActiveValue::Set(
|
||||
version.provides.contains(&ExtensionProvides::Snippets),
|
||||
),
|
||||
provides_debug_adapters: ActiveValue::Set(
|
||||
version.provides.contains(&ExtensionProvides::DebugAdapters),
|
||||
),
|
||||
download_count: ActiveValue::NotSet,
|
||||
}
|
||||
}))
|
||||
.on_conflict(OnConflict::new().do_nothing().to_owned())
|
||||
.exec_without_returning(&*tx)
|
||||
.await?;
|
||||
|
||||
if let Ok(db_version) = semver::Version::parse(&extension.latest_version)
|
||||
&& db_version >= latest_version.version
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut extension = extension.into_active_model();
|
||||
extension.latest_version = ActiveValue::Set(latest_version.version.to_string());
|
||||
extension.name = ActiveValue::set(latest_version.name.clone());
|
||||
extension::Entity::update(extension).exec(&*tx).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn record_extension_download(&self, extension: &str, version: &str) -> Result<bool> {
|
||||
self.transaction(|tx| async move {
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryId {
|
||||
Id,
|
||||
}
|
||||
|
||||
let extension_id: Option<ExtensionId> = extension::Entity::find()
|
||||
.filter(extension::Column::ExternalId.eq(extension))
|
||||
.select_only()
|
||||
.column(extension::Column::Id)
|
||||
.into_values::<_, QueryId>()
|
||||
.one(&*tx)
|
||||
.await?;
|
||||
let Some(extension_id) = extension_id else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
extension_version::Entity::update_many()
|
||||
.col_expr(
|
||||
extension_version::Column::DownloadCount,
|
||||
extension_version::Column::DownloadCount.into_expr().add(1),
|
||||
)
|
||||
.filter(
|
||||
extension_version::Column::ExtensionId
|
||||
.eq(extension_id)
|
||||
.and(extension_version::Column::Version.eq(version)),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
extension::Entity::update_many()
|
||||
.col_expr(
|
||||
extension::Column::TotalDownloadCount,
|
||||
extension::Column::TotalDownloadCount.into_expr().add(1),
|
||||
)
|
||||
.filter(extension::Column::Id.eq(extension_id))
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(true)
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_from_extension_and_version(
|
||||
extension: extension::Model,
|
||||
version: extension_version::Model,
|
||||
) -> ExtensionMetadata {
|
||||
let provides = version.provides();
|
||||
|
||||
ExtensionMetadata {
|
||||
id: extension.external_id.into(),
|
||||
manifest: cloud_api_types::ExtensionApiManifest {
|
||||
name: extension.name,
|
||||
version: version.version.into(),
|
||||
authors: version
|
||||
.authors
|
||||
.split(',')
|
||||
.map(|author| author.trim().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
description: Some(version.description),
|
||||
repository: version.repository,
|
||||
schema_version: Some(version.schema_version),
|
||||
wasm_api_version: version.wasm_api_version,
|
||||
provides,
|
||||
},
|
||||
|
||||
published_at: convert_time_to_chrono(version.published_at),
|
||||
download_count: extension.total_download_count as u64,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn convert_time_to_chrono(time: time::PrimitiveDateTime) -> chrono::DateTime<Utc> {
|
||||
chrono::DateTime::from_naive_utc_and_offset(
|
||||
#[allow(deprecated)]
|
||||
chrono::NaiveDateTime::from_timestamp_opt(time.assume_utc().unix_timestamp(), 0).unwrap(),
|
||||
Utc,
|
||||
)
|
||||
}
|
||||
281
crates/collab/src/db/queries/notifications.rs
Normal file
281
crates/collab/src/db/queries/notifications.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
use super::*;
|
||||
use anyhow::Context as _;
|
||||
use rpc::Notification;
|
||||
use util::ResultExt;
|
||||
|
||||
impl Database {
|
||||
/// Initializes the different kinds of notifications by upserting records for them.
|
||||
pub async fn initialize_notification_kinds(&mut self) -> Result<()> {
|
||||
let all_kinds = Notification::all_variant_names();
|
||||
let existing_kinds = notification_kind::Entity::find().all(&self.pool).await?;
|
||||
|
||||
let kinds_to_create: Vec<_> = all_kinds
|
||||
.iter()
|
||||
.filter(|&kind| {
|
||||
!existing_kinds
|
||||
.iter()
|
||||
.any(|existing| existing.name == **kind)
|
||||
})
|
||||
.map(|kind| notification_kind::ActiveModel {
|
||||
name: ActiveValue::Set((*kind).to_owned()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
|
||||
if !kinds_to_create.is_empty() {
|
||||
notification_kind::Entity::insert_many(kinds_to_create)
|
||||
.exec_without_returning(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let mut rows = notification_kind::Entity::find().stream(&self.pool).await?;
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
self.notification_kinds_by_name.insert(row.name, row.id);
|
||||
}
|
||||
|
||||
for name in Notification::all_variant_names() {
|
||||
if let Some(id) = self.notification_kinds_by_name.get(*name).copied() {
|
||||
self.notification_kinds_by_id.insert(id, name);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the notifications for the given recipient.
|
||||
pub async fn get_notifications(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
limit: usize,
|
||||
before_id: Option<NotificationId>,
|
||||
) -> Result<Vec<proto::Notification>> {
|
||||
self.transaction(|tx| async move {
|
||||
let mut result = Vec::new();
|
||||
let mut condition =
|
||||
Condition::all().add(notification::Column::RecipientId.eq(recipient_id));
|
||||
|
||||
if let Some(before_id) = before_id {
|
||||
condition = condition.add(notification::Column::Id.lt(before_id));
|
||||
}
|
||||
|
||||
let mut rows = notification::Entity::find()
|
||||
.filter(condition)
|
||||
.order_by_desc(notification::Column::Id)
|
||||
.limit(limit as u64)
|
||||
.stream(&*tx)
|
||||
.await?;
|
||||
while let Some(row) = rows.next().await {
|
||||
let row = row?;
|
||||
if let Some(proto) = model_to_proto(self, row).log_err() {
|
||||
result.push(proto);
|
||||
}
|
||||
}
|
||||
result.reverse();
|
||||
Ok(result)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Creates a notification. If `avoid_duplicates` is set to true, then avoid
|
||||
/// creating a new notification if the given recipient already has an
|
||||
/// unread notification with the given kind and entity id.
|
||||
pub async fn create_notification(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
notification: Notification,
|
||||
avoid_duplicates: bool,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Option<(UserId, proto::Notification)>> {
|
||||
if avoid_duplicates
|
||||
&& self
|
||||
.find_notification(recipient_id, ¬ification, tx)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let proto = notification.to_proto();
|
||||
let kind = notification_kind_from_proto(self, &proto)?;
|
||||
let model = notification::ActiveModel {
|
||||
recipient_id: ActiveValue::Set(recipient_id),
|
||||
kind: ActiveValue::Set(kind),
|
||||
entity_id: ActiveValue::Set(proto.entity_id.map(|id| id as i32)),
|
||||
content: ActiveValue::Set(proto.content.clone()),
|
||||
..Default::default()
|
||||
}
|
||||
.save(tx)
|
||||
.await?;
|
||||
|
||||
Ok(Some((
|
||||
recipient_id,
|
||||
proto::Notification {
|
||||
id: model.id.as_ref().to_proto(),
|
||||
kind: proto.kind,
|
||||
timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64,
|
||||
is_read: false,
|
||||
response: None,
|
||||
content: proto.content,
|
||||
entity_id: proto.entity_id,
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
/// Remove an unread notification with the given recipient, kind and
|
||||
/// entity id.
|
||||
pub async fn remove_notification(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
notification: Notification,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Option<NotificationId>> {
|
||||
let id = self
|
||||
.find_notification(recipient_id, ¬ification, tx)
|
||||
.await?;
|
||||
if let Some(id) = id {
|
||||
notification::Entity::delete_by_id(id).exec(tx).await?;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Populate the response for the notification with the given kind and
|
||||
/// entity id.
|
||||
pub async fn mark_notification_as_read_with_response(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
notification: &Notification,
|
||||
response: bool,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Option<(UserId, proto::Notification)>> {
|
||||
self.mark_notification_as_read_internal(recipient_id, notification, Some(response), tx)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Marks the given notification as read.
|
||||
pub async fn mark_notification_as_read(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
notification: &Notification,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Option<(UserId, proto::Notification)>> {
|
||||
self.mark_notification_as_read_internal(recipient_id, notification, None, tx)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Marks the notification with the given ID as read.
|
||||
pub async fn mark_notification_as_read_by_id(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
notification_id: NotificationId,
|
||||
) -> Result<NotificationBatch> {
|
||||
self.transaction(|tx| async move {
|
||||
let row = notification::Entity::update(notification::ActiveModel {
|
||||
id: ActiveValue::Unchanged(notification_id),
|
||||
recipient_id: ActiveValue::Unchanged(recipient_id),
|
||||
is_read: ActiveValue::Set(true),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
Ok(model_to_proto(self, row)
|
||||
.map(|notification| (recipient_id, notification))
|
||||
.into_iter()
|
||||
.collect())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn mark_notification_as_read_internal(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
notification: &Notification,
|
||||
response: Option<bool>,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Option<(UserId, proto::Notification)>> {
|
||||
if let Some(id) = self
|
||||
.find_notification(recipient_id, notification, tx)
|
||||
.await?
|
||||
{
|
||||
let row = notification::Entity::update(notification::ActiveModel {
|
||||
id: ActiveValue::Unchanged(id),
|
||||
recipient_id: ActiveValue::Unchanged(recipient_id),
|
||||
is_read: ActiveValue::Set(true),
|
||||
response: if let Some(response) = response {
|
||||
ActiveValue::Set(Some(response))
|
||||
} else {
|
||||
ActiveValue::NotSet
|
||||
},
|
||||
..Default::default()
|
||||
})
|
||||
.exec(tx)
|
||||
.await?;
|
||||
Ok(model_to_proto(self, row)
|
||||
.map(|notification| (recipient_id, notification))
|
||||
.ok())
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Find an unread notification by its recipient, kind and entity id.
|
||||
async fn find_notification(
|
||||
&self,
|
||||
recipient_id: UserId,
|
||||
notification: &Notification,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Option<NotificationId>> {
|
||||
let proto = notification.to_proto();
|
||||
let kind = notification_kind_from_proto(self, &proto)?;
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryIds {
|
||||
Id,
|
||||
}
|
||||
|
||||
Ok(notification::Entity::find()
|
||||
.select_only()
|
||||
.column(notification::Column::Id)
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(notification::Column::RecipientId.eq(recipient_id))
|
||||
.add(notification::Column::IsRead.eq(false))
|
||||
.add(notification::Column::Kind.eq(kind))
|
||||
.add(if proto.entity_id.is_some() {
|
||||
notification::Column::EntityId.eq(proto.entity_id)
|
||||
} else {
|
||||
notification::Column::EntityId.is_null()
|
||||
}),
|
||||
)
|
||||
.into_values::<_, QueryIds>()
|
||||
.one(tx)
|
||||
.await?)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model_to_proto(this: &Database, row: notification::Model) -> Result<proto::Notification> {
|
||||
let kind = this
|
||||
.notification_kinds_by_id
|
||||
.get(&row.kind)
|
||||
.context("Unknown notification kind")?;
|
||||
Ok(proto::Notification {
|
||||
id: row.id.to_proto(),
|
||||
kind: (*kind).to_owned(),
|
||||
timestamp: row.created_at.assume_utc().unix_timestamp() as u64,
|
||||
is_read: row.is_read,
|
||||
response: row.response,
|
||||
content: row.content,
|
||||
entity_id: row.entity_id.map(|id| id as u64),
|
||||
})
|
||||
}
|
||||
|
||||
fn notification_kind_from_proto(
|
||||
this: &Database,
|
||||
proto: &proto::Notification,
|
||||
) -> Result<NotificationKindId> {
|
||||
Ok(this
|
||||
.notification_kinds_by_name
|
||||
.get(&proto.kind)
|
||||
.copied()
|
||||
.with_context(|| format!("invalid notification kind {:?}", proto.kind))?)
|
||||
}
|
||||
1397
crates/collab/src/db/queries/projects.rs
Normal file
1397
crates/collab/src/db/queries/projects.rs
Normal file
File diff suppressed because it is too large
Load Diff
1432
crates/collab/src/db/queries/rooms.rs
Normal file
1432
crates/collab/src/db/queries/rooms.rs
Normal file
File diff suppressed because it is too large
Load Diff
230
crates/collab/src/db/queries/servers.rs
Normal file
230
crates/collab/src/db/queries/servers.rs
Normal file
@@ -0,0 +1,230 @@
|
||||
use super::*;
|
||||
|
||||
impl Database {
|
||||
/// Creates a new server in the given environment.
|
||||
pub async fn create_server(&self, environment: &str) -> Result<ServerId> {
|
||||
self.transaction(|tx| async move {
|
||||
let server = server::ActiveModel {
|
||||
environment: ActiveValue::set(environment.into()),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
Ok(server.id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Returns the IDs of resources associated with stale servers.
|
||||
///
|
||||
/// A server is stale if it is in the specified `environment` and does not
|
||||
/// match the provided `new_server_id`.
|
||||
pub async fn stale_server_resource_ids(
|
||||
&self,
|
||||
environment: &str,
|
||||
new_server_id: ServerId,
|
||||
) -> Result<(Vec<RoomId>, Vec<ChannelId>)> {
|
||||
self.transaction(|tx| async move {
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryRoomIds {
|
||||
RoomId,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
|
||||
enum QueryChannelIds {
|
||||
ChannelId,
|
||||
}
|
||||
|
||||
let stale_server_epochs = self
|
||||
.stale_server_ids(environment, new_server_id, &tx)
|
||||
.await?;
|
||||
let room_ids = room_participant::Entity::find()
|
||||
.select_only()
|
||||
.column(room_participant::Column::RoomId)
|
||||
.distinct()
|
||||
.filter(
|
||||
room_participant::Column::AnsweringConnectionServerId
|
||||
.is_in(stale_server_epochs.iter().copied()),
|
||||
)
|
||||
.into_values::<_, QueryRoomIds>()
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
let channel_ids = channel_buffer_collaborator::Entity::find()
|
||||
.select_only()
|
||||
.column(channel_buffer_collaborator::Column::ChannelId)
|
||||
.distinct()
|
||||
.filter(
|
||||
channel_buffer_collaborator::Column::ConnectionServerId
|
||||
.is_in(stale_server_epochs.iter().copied()),
|
||||
)
|
||||
.into_values::<_, QueryChannelIds>()
|
||||
.all(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok((room_ids, channel_ids))
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Delete all channel chat participants from previous servers
|
||||
pub async fn delete_stale_channel_chat_participants(
|
||||
&self,
|
||||
environment: &str,
|
||||
new_server_id: ServerId,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
let stale_server_epochs = self
|
||||
.stale_server_ids(environment, new_server_id, &tx)
|
||||
.await?;
|
||||
|
||||
channel_chat_participant::Entity::delete_many()
|
||||
.filter(
|
||||
channel_chat_participant::Column::ConnectionServerId
|
||||
.is_in(stale_server_epochs.iter().copied()),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn clear_old_worktree_entries(&self, server_id: ServerId) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
use sea_orm::Statement;
|
||||
use sea_orm::sea_query::{Expr, Query};
|
||||
|
||||
loop {
|
||||
let delete_query = Query::delete()
|
||||
.from_table(worktree_entry::Entity)
|
||||
.and_where(
|
||||
Expr::tuple([
|
||||
Expr::col((worktree_entry::Entity, worktree_entry::Column::ProjectId))
|
||||
.into(),
|
||||
Expr::col((worktree_entry::Entity, worktree_entry::Column::WorktreeId))
|
||||
.into(),
|
||||
Expr::col((worktree_entry::Entity, worktree_entry::Column::Id)).into(),
|
||||
])
|
||||
.in_subquery(
|
||||
Query::select()
|
||||
.columns([
|
||||
(worktree_entry::Entity, worktree_entry::Column::ProjectId),
|
||||
(worktree_entry::Entity, worktree_entry::Column::WorktreeId),
|
||||
(worktree_entry::Entity, worktree_entry::Column::Id),
|
||||
])
|
||||
.from(worktree_entry::Entity)
|
||||
.inner_join(
|
||||
project::Entity,
|
||||
Expr::col((project::Entity, project::Column::Id)).equals((
|
||||
worktree_entry::Entity,
|
||||
worktree_entry::Column::ProjectId,
|
||||
)),
|
||||
)
|
||||
.and_where(project::Column::HostConnectionServerId.ne(server_id))
|
||||
.limit(10000)
|
||||
.to_owned(),
|
||||
),
|
||||
)
|
||||
.to_owned();
|
||||
|
||||
let statement = Statement::from_sql_and_values(
|
||||
tx.get_database_backend(),
|
||||
delete_query
|
||||
.to_string(sea_orm::sea_query::PostgresQueryBuilder)
|
||||
.as_str(),
|
||||
vec![],
|
||||
);
|
||||
|
||||
let result = tx.execute(statement).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
loop {
|
||||
let delete_query = Query::delete()
|
||||
.from_table(project_repository_statuses::Entity)
|
||||
.and_where(
|
||||
Expr::tuple([Expr::col((
|
||||
project_repository_statuses::Entity,
|
||||
project_repository_statuses::Column::ProjectId,
|
||||
))
|
||||
.into()])
|
||||
.in_subquery(
|
||||
Query::select()
|
||||
.columns([(
|
||||
project_repository_statuses::Entity,
|
||||
project_repository_statuses::Column::ProjectId,
|
||||
)])
|
||||
.from(project_repository_statuses::Entity)
|
||||
.inner_join(
|
||||
project::Entity,
|
||||
Expr::col((project::Entity, project::Column::Id)).equals((
|
||||
project_repository_statuses::Entity,
|
||||
project_repository_statuses::Column::ProjectId,
|
||||
)),
|
||||
)
|
||||
.and_where(project::Column::HostConnectionServerId.ne(server_id))
|
||||
.limit(10000)
|
||||
.to_owned(),
|
||||
),
|
||||
)
|
||||
.to_owned();
|
||||
|
||||
let statement = Statement::from_sql_and_values(
|
||||
tx.get_database_backend(),
|
||||
delete_query
|
||||
.to_string(sea_orm::sea_query::PostgresQueryBuilder)
|
||||
.as_str(),
|
||||
vec![],
|
||||
);
|
||||
|
||||
let result = tx.execute(statement).await?;
|
||||
if result.rows_affected() == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Deletes any stale servers in the environment that don't match the `new_server_id`.
|
||||
pub async fn delete_stale_servers(
|
||||
&self,
|
||||
environment: &str,
|
||||
new_server_id: ServerId,
|
||||
) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
server::Entity::delete_many()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(server::Column::Environment.eq(environment))
|
||||
.add(server::Column::Id.ne(new_server_id)),
|
||||
)
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn stale_server_ids(
|
||||
&self,
|
||||
environment: &str,
|
||||
new_server_id: ServerId,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Vec<ServerId>> {
|
||||
let stale_servers = server::Entity::find()
|
||||
.filter(
|
||||
Condition::all()
|
||||
.add(server::Column::Environment.eq(environment))
|
||||
.add(server::Column::Id.ne(new_server_id)),
|
||||
)
|
||||
.all(tx)
|
||||
.await?;
|
||||
Ok(stale_servers.into_iter().map(|server| server.id).collect())
|
||||
}
|
||||
}
|
||||
77
crates/collab/src/db/queries/shared_threads.rs
Normal file
77
crates/collab/src/db/queries/shared_threads.rs
Normal file
@@ -0,0 +1,77 @@
|
||||
use chrono::Utc;
|
||||
|
||||
use super::*;
|
||||
use crate::db::tables::shared_thread;
|
||||
|
||||
impl Database {
|
||||
pub async fn upsert_shared_thread(
|
||||
&self,
|
||||
id: SharedThreadId,
|
||||
user_id: UserId,
|
||||
title: &str,
|
||||
data: Vec<u8>,
|
||||
) -> Result<()> {
|
||||
let title = title.to_string();
|
||||
self.transaction(|tx| {
|
||||
let title = title.clone();
|
||||
let data = data.clone();
|
||||
async move {
|
||||
let now = Utc::now().naive_utc();
|
||||
|
||||
let existing = shared_thread::Entity::find_by_id(id).one(&*tx).await?;
|
||||
|
||||
match existing {
|
||||
Some(existing) => {
|
||||
if existing.user_id != user_id {
|
||||
Err(anyhow!("Cannot update shared thread owned by another user"))?;
|
||||
}
|
||||
|
||||
let mut active: shared_thread::ActiveModel = existing.into();
|
||||
active.title = ActiveValue::Set(title);
|
||||
active.data = ActiveValue::Set(data);
|
||||
active.updated_at = ActiveValue::Set(now);
|
||||
active.update(&*tx).await?;
|
||||
}
|
||||
None => {
|
||||
shared_thread::ActiveModel {
|
||||
id: ActiveValue::Set(id),
|
||||
user_id: ActiveValue::Set(user_id),
|
||||
title: ActiveValue::Set(title),
|
||||
data: ActiveValue::Set(data),
|
||||
created_at: ActiveValue::Set(now),
|
||||
updated_at: ActiveValue::Set(now),
|
||||
}
|
||||
.insert(&*tx)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_shared_thread(
|
||||
&self,
|
||||
share_id: SharedThreadId,
|
||||
) -> Result<Option<(shared_thread::Model, String)>> {
|
||||
self.transaction(|tx| async move {
|
||||
let Some(thread) = shared_thread::Entity::find_by_id(share_id)
|
||||
.one(&*tx)
|
||||
.await?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let user = user::Entity::find_by_id(thread.user_id).one(&*tx).await?;
|
||||
|
||||
let username = user
|
||||
.map(|u| u.github_login)
|
||||
.unwrap_or_else(|| "Unknown".to_string());
|
||||
|
||||
Ok(Some((thread, username)))
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
176
crates/collab/src/db/queries/users.rs
Normal file
176
crates/collab/src/db/queries/users.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use chrono::NaiveDateTime;
|
||||
|
||||
use super::*;
|
||||
|
||||
impl Database {
|
||||
/// Creates a new user.
|
||||
pub async fn create_user(
|
||||
&self,
|
||||
email_address: &str,
|
||||
name: Option<&str>,
|
||||
admin: bool,
|
||||
params: NewUserParams,
|
||||
) -> Result<NewUserResult> {
|
||||
self.transaction(|tx| async {
|
||||
let tx = tx;
|
||||
let user = user::Entity::insert(user::ActiveModel {
|
||||
email_address: ActiveValue::set(Some(email_address.into())),
|
||||
name: ActiveValue::set(name.map(|s| s.into())),
|
||||
github_login: ActiveValue::set(params.github_login.clone()),
|
||||
github_user_id: ActiveValue::set(params.github_user_id),
|
||||
admin: ActiveValue::set(admin),
|
||||
..Default::default()
|
||||
})
|
||||
.on_conflict(
|
||||
OnConflict::column(user::Column::GithubUserId)
|
||||
.update_columns([
|
||||
user::Column::Admin,
|
||||
user::Column::EmailAddress,
|
||||
user::Column::GithubLogin,
|
||||
])
|
||||
.to_owned(),
|
||||
)
|
||||
.exec_with_returning(&*tx)
|
||||
.await?;
|
||||
|
||||
Ok(NewUserResult { user_id: user.id })
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_or_create_user_by_github_account(
|
||||
&self,
|
||||
github_login: &str,
|
||||
github_user_id: i32,
|
||||
github_email: Option<&str>,
|
||||
github_name: Option<&str>,
|
||||
github_user_created_at: DateTimeUtc,
|
||||
initial_channel_id: Option<ChannelId>,
|
||||
) -> Result<user::Model> {
|
||||
self.transaction(|tx| async move {
|
||||
self.update_or_create_user_by_github_account_tx(
|
||||
github_login,
|
||||
github_user_id,
|
||||
github_email,
|
||||
github_name,
|
||||
github_user_created_at.naive_utc(),
|
||||
initial_channel_id,
|
||||
&tx,
|
||||
)
|
||||
.await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn update_or_create_user_by_github_account_tx(
|
||||
&self,
|
||||
github_login: &str,
|
||||
github_user_id: i32,
|
||||
github_email: Option<&str>,
|
||||
github_name: Option<&str>,
|
||||
github_user_created_at: NaiveDateTime,
|
||||
initial_channel_id: Option<ChannelId>,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<user::Model> {
|
||||
if let Some(existing_user) = self
|
||||
.get_user_by_github_user_id_or_github_login(github_user_id, github_login, tx)
|
||||
.await?
|
||||
{
|
||||
let mut existing_user = existing_user.into_active_model();
|
||||
existing_user.github_login = ActiveValue::set(github_login.into());
|
||||
existing_user.github_user_created_at = ActiveValue::set(Some(github_user_created_at));
|
||||
|
||||
if let Some(github_email) = github_email {
|
||||
existing_user.email_address = ActiveValue::set(Some(github_email.into()));
|
||||
}
|
||||
|
||||
if let Some(github_name) = github_name {
|
||||
existing_user.name = ActiveValue::set(Some(github_name.into()));
|
||||
}
|
||||
|
||||
Ok(existing_user.update(tx).await?)
|
||||
} else {
|
||||
let user = user::Entity::insert(user::ActiveModel {
|
||||
email_address: ActiveValue::set(github_email.map(|email| email.into())),
|
||||
name: ActiveValue::set(github_name.map(|name| name.into())),
|
||||
github_login: ActiveValue::set(github_login.into()),
|
||||
github_user_id: ActiveValue::set(github_user_id),
|
||||
github_user_created_at: ActiveValue::set(Some(github_user_created_at)),
|
||||
admin: ActiveValue::set(false),
|
||||
..Default::default()
|
||||
})
|
||||
.exec_with_returning(tx)
|
||||
.await?;
|
||||
if let Some(channel_id) = initial_channel_id {
|
||||
channel_member::Entity::insert(channel_member::ActiveModel {
|
||||
id: ActiveValue::NotSet,
|
||||
channel_id: ActiveValue::Set(channel_id),
|
||||
user_id: ActiveValue::Set(user.id),
|
||||
accepted: ActiveValue::Set(true),
|
||||
role: ActiveValue::Set(ChannelRole::Guest),
|
||||
})
|
||||
.exec(tx)
|
||||
.await?;
|
||||
}
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to retrieve a user, first by their GitHub user ID, and then by their GitHub login.
|
||||
///
|
||||
/// Returns `None` if a user is not found with this GitHub user ID or GitHub login.
|
||||
pub async fn get_user_by_github_user_id_or_github_login(
|
||||
&self,
|
||||
github_user_id: i32,
|
||||
github_login: &str,
|
||||
tx: &DatabaseTransaction,
|
||||
) -> Result<Option<user::Model>> {
|
||||
if let Some(user_by_github_user_id) = user::Entity::find()
|
||||
.filter(user::Column::GithubUserId.eq(github_user_id))
|
||||
.one(tx)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(user_by_github_user_id));
|
||||
}
|
||||
|
||||
if let Some(user_by_github_login) = user::Entity::find()
|
||||
.filter(user::Column::GithubLogin.eq(github_login))
|
||||
.one(tx)
|
||||
.await?
|
||||
{
|
||||
return Ok(Some(user_by_github_login));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// get_all_users returns the next page of users. To get more call again with
|
||||
/// the same limit and the page incremented by 1.
|
||||
pub async fn get_all_users(&self, page: u32, limit: u32) -> Result<Vec<user::Model>> {
|
||||
self.transaction(|tx| async move {
|
||||
Ok(user::Entity::find()
|
||||
.order_by_asc(user::Column::GithubLogin)
|
||||
.limit(limit as u64)
|
||||
.offset(page as u64 * limit as u64)
|
||||
.all(&*tx)
|
||||
.await?)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Sets "connected_once" on the user for analytics.
|
||||
pub async fn set_user_connected_once(&self, id: UserId, connected_once: bool) -> Result<()> {
|
||||
self.transaction(|tx| async move {
|
||||
user::Entity::update_many()
|
||||
.filter(user::Column::Id.eq(id))
|
||||
.set(user::ActiveModel {
|
||||
connected_once: ActiveValue::set(connected_once),
|
||||
..Default::default()
|
||||
})
|
||||
.exec(&*tx)
|
||||
.await?;
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
}
|
||||
29
crates/collab/src/db/tables.rs
Normal file
29
crates/collab/src/db/tables.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
pub mod buffer;
|
||||
pub mod buffer_operation;
|
||||
pub mod buffer_snapshot;
|
||||
pub mod channel;
|
||||
pub mod channel_buffer_collaborator;
|
||||
pub mod channel_chat_participant;
|
||||
pub mod channel_member;
|
||||
pub mod contact;
|
||||
pub mod contributor;
|
||||
pub mod extension;
|
||||
pub mod extension_version;
|
||||
pub mod follower;
|
||||
pub mod language_server;
|
||||
pub mod notification;
|
||||
pub mod notification_kind;
|
||||
pub mod observed_buffer_edits;
|
||||
pub mod project;
|
||||
pub mod project_collaborator;
|
||||
pub mod project_repository;
|
||||
pub mod project_repository_statuses;
|
||||
pub mod room;
|
||||
pub mod room_participant;
|
||||
pub mod server;
|
||||
pub mod shared_thread;
|
||||
pub mod user;
|
||||
pub mod worktree;
|
||||
pub mod worktree_diagnostic_summary;
|
||||
pub mod worktree_entry;
|
||||
pub mod worktree_settings_file;
|
||||
48
crates/collab/src/db/tables/buffer.rs
Normal file
48
crates/collab/src/db/tables/buffer.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
use crate::db::{BufferId, ChannelId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "buffers")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: BufferId,
|
||||
pub epoch: i32,
|
||||
pub channel_id: ChannelId,
|
||||
pub latest_operation_epoch: Option<i32>,
|
||||
pub latest_operation_lamport_timestamp: Option<i32>,
|
||||
pub latest_operation_replica_id: Option<i32>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::buffer_operation::Entity")]
|
||||
Operations,
|
||||
#[sea_orm(has_many = "super::buffer_snapshot::Entity")]
|
||||
Snapshots,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::channel::Entity",
|
||||
from = "Column::ChannelId",
|
||||
to = "super::channel::Column::Id"
|
||||
)]
|
||||
Channel,
|
||||
}
|
||||
|
||||
impl Related<super::buffer_operation::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Operations.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::buffer_snapshot::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Snapshots.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::channel::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Channel.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
34
crates/collab/src/db/tables/buffer_operation.rs
Normal file
34
crates/collab/src/db/tables/buffer_operation.rs
Normal file
@@ -0,0 +1,34 @@
|
||||
use crate::db::BufferId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "buffer_operations")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub buffer_id: BufferId,
|
||||
#[sea_orm(primary_key)]
|
||||
pub epoch: i32,
|
||||
#[sea_orm(primary_key)]
|
||||
pub lamport_timestamp: i32,
|
||||
#[sea_orm(primary_key)]
|
||||
pub replica_id: i32,
|
||||
pub value: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::buffer::Entity",
|
||||
from = "Column::BufferId",
|
||||
to = "super::buffer::Column::Id"
|
||||
)]
|
||||
Buffer,
|
||||
}
|
||||
|
||||
impl Related<super::buffer::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Buffer.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
31
crates/collab/src/db/tables/buffer_snapshot.rs
Normal file
31
crates/collab/src/db/tables/buffer_snapshot.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use crate::db::BufferId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "buffer_snapshots")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub buffer_id: BufferId,
|
||||
#[sea_orm(primary_key)]
|
||||
pub epoch: i32,
|
||||
pub text: String,
|
||||
pub operation_serialization_version: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::buffer::Entity",
|
||||
from = "Column::BufferId",
|
||||
to = "super::buffer::Column::Id"
|
||||
)]
|
||||
Buffer,
|
||||
}
|
||||
|
||||
impl Related<super::buffer::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Buffer.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
95
crates/collab/src/db/tables/channel.rs
Normal file
95
crates/collab/src/db/tables/channel.rs
Normal file
@@ -0,0 +1,95 @@
|
||||
use crate::db::{ChannelId, ChannelVisibility};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "channels")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: ChannelId,
|
||||
pub name: String,
|
||||
pub visibility: ChannelVisibility,
|
||||
pub parent_path: String,
|
||||
pub requires_zed_cla: bool,
|
||||
/// The order of this channel relative to its siblings within the same parent.
|
||||
/// Lower values appear first. Channels are sorted by parent_path first, then by channel_order.
|
||||
pub channel_order: i32,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn parent_id(&self) -> Option<ChannelId> {
|
||||
self.ancestors().last()
|
||||
}
|
||||
|
||||
pub fn is_root(&self) -> bool {
|
||||
self.parent_path.is_empty()
|
||||
}
|
||||
|
||||
pub fn root_id(&self) -> ChannelId {
|
||||
self.ancestors().next().unwrap_or(self.id)
|
||||
}
|
||||
|
||||
pub fn ancestors(&self) -> impl Iterator<Item = ChannelId> + '_ {
|
||||
self.parent_path
|
||||
.trim_end_matches('/')
|
||||
.split('/')
|
||||
.filter_map(|id| Some(ChannelId::from_proto(id.parse().ok()?)))
|
||||
}
|
||||
|
||||
pub fn ancestors_including_self(&self) -> impl Iterator<Item = ChannelId> + '_ {
|
||||
self.ancestors().chain(Some(self.id))
|
||||
}
|
||||
|
||||
pub fn path(&self) -> String {
|
||||
format!("{}{}/", self.parent_path, self.id)
|
||||
}
|
||||
|
||||
pub fn descendant_path_filter(&self) -> String {
|
||||
format!("{}{}/%", self.parent_path, self.id)
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_one = "super::room::Entity")]
|
||||
Room,
|
||||
#[sea_orm(has_one = "super::buffer::Entity")]
|
||||
Buffer,
|
||||
#[sea_orm(has_many = "super::channel_member::Entity")]
|
||||
Member,
|
||||
#[sea_orm(has_many = "super::channel_buffer_collaborator::Entity")]
|
||||
BufferCollaborators,
|
||||
#[sea_orm(has_many = "super::channel_chat_participant::Entity")]
|
||||
ChatParticipants,
|
||||
}
|
||||
|
||||
impl Related<super::channel_member::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Member.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::room::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Room.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::buffer::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Buffer.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::channel_buffer_collaborator::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::BufferCollaborators.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::channel_chat_participant::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ChatParticipants.def()
|
||||
}
|
||||
}
|
||||
43
crates/collab/src/db/tables/channel_buffer_collaborator.rs
Normal file
43
crates/collab/src/db/tables/channel_buffer_collaborator.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use crate::db::{ChannelBufferCollaboratorId, ChannelId, ReplicaId, ServerId, UserId};
|
||||
use rpc::ConnectionId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "channel_buffer_collaborators")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: ChannelBufferCollaboratorId,
|
||||
pub channel_id: ChannelId,
|
||||
pub connection_id: i32,
|
||||
pub connection_server_id: ServerId,
|
||||
pub connection_lost: bool,
|
||||
pub user_id: UserId,
|
||||
pub replica_id: ReplicaId,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn connection(&self) -> ConnectionId {
|
||||
ConnectionId {
|
||||
owner_id: self.connection_server_id.0 as u32,
|
||||
id: self.connection_id as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::channel::Entity",
|
||||
from = "Column::ChannelId",
|
||||
to = "super::channel::Column::Id"
|
||||
)]
|
||||
Channel,
|
||||
}
|
||||
|
||||
impl Related<super::channel::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Channel.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
41
crates/collab/src/db/tables/channel_chat_participant.rs
Normal file
41
crates/collab/src/db/tables/channel_chat_participant.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use crate::db::{ChannelChatParticipantId, ChannelId, ServerId, UserId};
|
||||
use rpc::ConnectionId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "channel_chat_participants")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: ChannelChatParticipantId,
|
||||
pub channel_id: ChannelId,
|
||||
pub user_id: UserId,
|
||||
pub connection_id: i32,
|
||||
pub connection_server_id: ServerId,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn connection(&self) -> ConnectionId {
|
||||
ConnectionId {
|
||||
owner_id: self.connection_server_id.0 as u32,
|
||||
id: self.connection_id as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::channel::Entity",
|
||||
from = "Column::ChannelId",
|
||||
to = "super::channel::Column::Id"
|
||||
)]
|
||||
Channel,
|
||||
}
|
||||
|
||||
impl Related<super::channel::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Channel.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
75
crates/collab/src/db/tables/channel_member.rs
Normal file
75
crates/collab/src/db/tables/channel_member.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use crate::db::{ChannelId, ChannelMemberId, ChannelRole, UserId, channel_member};
|
||||
use rpc::proto;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "channel_members")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: ChannelMemberId,
|
||||
pub channel_id: ChannelId,
|
||||
pub user_id: UserId,
|
||||
pub accepted: bool,
|
||||
pub role: ChannelRole,
|
||||
}
|
||||
|
||||
impl From<Model> for proto::ChannelMember {
|
||||
fn from(member: Model) -> Self {
|
||||
Self {
|
||||
role: member.role.into(),
|
||||
user_id: member.user_id.to_proto(),
|
||||
kind: if member.accepted {
|
||||
proto::channel_member::Kind::Member
|
||||
} else {
|
||||
proto::channel_member::Kind::Invitee
|
||||
}
|
||||
.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::channel::Entity",
|
||||
from = "Column::ChannelId",
|
||||
to = "super::channel::Column::Id"
|
||||
)]
|
||||
Channel,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::channel::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Channel.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct UserToChannel;
|
||||
|
||||
impl Linked for UserToChannel {
|
||||
type FromEntity = super::user::Entity;
|
||||
|
||||
type ToEntity = super::channel::Entity;
|
||||
|
||||
fn link(&self) -> Vec<RelationDef> {
|
||||
vec![
|
||||
channel_member::Relation::User.def().rev(),
|
||||
channel_member::Relation::Channel.def(),
|
||||
]
|
||||
}
|
||||
}
|
||||
32
crates/collab/src/db/tables/contact.rs
Normal file
32
crates/collab/src/db/tables/contact.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use crate::db::{ContactId, UserId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "contacts")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: ContactId,
|
||||
pub user_id_a: UserId,
|
||||
pub user_id_b: UserId,
|
||||
pub a_to_b: bool,
|
||||
pub should_notify: bool,
|
||||
pub accepted: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::room_participant::Entity",
|
||||
from = "Column::UserIdA",
|
||||
to = "super::room_participant::Column::UserId"
|
||||
)]
|
||||
UserARoomParticipant,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::room_participant::Entity",
|
||||
from = "Column::UserIdB",
|
||||
to = "super::room_participant::Column::UserId"
|
||||
)]
|
||||
UserBRoomParticipant,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
30
crates/collab/src/db/tables/contributor.rs
Normal file
30
crates/collab/src/db/tables/contributor.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use crate::db::UserId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::Serialize;
|
||||
|
||||
/// A user who has signed the CLA.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)]
|
||||
#[sea_orm(table_name = "contributors")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub user_id: UserId,
|
||||
pub signed_at: DateTime,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
27
crates/collab/src/db/tables/extension.rs
Normal file
27
crates/collab/src/db/tables/extension.rs
Normal file
@@ -0,0 +1,27 @@
|
||||
use crate::db::ExtensionId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "extensions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: ExtensionId,
|
||||
pub external_id: String,
|
||||
pub name: String,
|
||||
pub latest_version: String,
|
||||
pub total_download_count: i64,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_one = "super::extension_version::Entity")]
|
||||
LatestVersion,
|
||||
}
|
||||
|
||||
impl Related<super::extension_version::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::LatestVersion.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
102
crates/collab/src/db/tables/extension_version.rs
Normal file
102
crates/collab/src/db/tables/extension_version.rs
Normal file
@@ -0,0 +1,102 @@
|
||||
use crate::db::ExtensionId;
|
||||
use cloud_api_types::ExtensionProvides;
|
||||
use collections::BTreeSet;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use time::PrimitiveDateTime;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "extension_versions")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub extension_id: ExtensionId,
|
||||
#[sea_orm(primary_key)]
|
||||
pub version: String,
|
||||
pub published_at: PrimitiveDateTime,
|
||||
pub authors: String,
|
||||
pub repository: String,
|
||||
pub description: String,
|
||||
pub schema_version: i32,
|
||||
pub wasm_api_version: Option<String>,
|
||||
pub download_count: i64,
|
||||
pub provides_themes: bool,
|
||||
pub provides_icon_themes: bool,
|
||||
pub provides_languages: bool,
|
||||
pub provides_grammars: bool,
|
||||
pub provides_language_servers: bool,
|
||||
pub provides_context_servers: bool,
|
||||
pub provides_agent_servers: bool,
|
||||
pub provides_slash_commands: bool,
|
||||
pub provides_indexed_docs_providers: bool,
|
||||
pub provides_snippets: bool,
|
||||
pub provides_debug_adapters: bool,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn provides(&self) -> BTreeSet<ExtensionProvides> {
|
||||
let mut provides = BTreeSet::default();
|
||||
if self.provides_themes {
|
||||
provides.insert(ExtensionProvides::Themes);
|
||||
}
|
||||
|
||||
if self.provides_icon_themes {
|
||||
provides.insert(ExtensionProvides::IconThemes);
|
||||
}
|
||||
|
||||
if self.provides_languages {
|
||||
provides.insert(ExtensionProvides::Languages);
|
||||
}
|
||||
|
||||
if self.provides_grammars {
|
||||
provides.insert(ExtensionProvides::Grammars);
|
||||
}
|
||||
|
||||
if self.provides_language_servers {
|
||||
provides.insert(ExtensionProvides::LanguageServers);
|
||||
}
|
||||
|
||||
if self.provides_context_servers {
|
||||
provides.insert(ExtensionProvides::ContextServers);
|
||||
}
|
||||
|
||||
if self.provides_agent_servers {
|
||||
provides.insert(ExtensionProvides::AgentServers);
|
||||
}
|
||||
|
||||
if self.provides_slash_commands {
|
||||
provides.insert(ExtensionProvides::SlashCommands);
|
||||
}
|
||||
|
||||
if self.provides_indexed_docs_providers {
|
||||
provides.insert(ExtensionProvides::IndexedDocsProviders);
|
||||
}
|
||||
|
||||
if self.provides_snippets {
|
||||
provides.insert(ExtensionProvides::Snippets);
|
||||
}
|
||||
|
||||
if self.provides_debug_adapters {
|
||||
provides.insert(ExtensionProvides::DebugAdapters);
|
||||
}
|
||||
|
||||
provides
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::extension::Entity",
|
||||
from = "Column::ExtensionId",
|
||||
to = "super::extension::Column::Id"
|
||||
on_condition = r#"super::extension::Column::LatestVersion.into_expr().eq(Column::Version.into_expr())"#
|
||||
)]
|
||||
Extension,
|
||||
}
|
||||
|
||||
impl Related<super::extension::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Extension.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
50
crates/collab/src/db/tables/follower.rs
Normal file
50
crates/collab/src/db/tables/follower.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use crate::db::{FollowerId, ProjectId, RoomId, ServerId};
|
||||
use rpc::ConnectionId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "followers")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: FollowerId,
|
||||
pub room_id: RoomId,
|
||||
pub project_id: ProjectId,
|
||||
pub leader_connection_server_id: ServerId,
|
||||
pub leader_connection_id: i32,
|
||||
pub follower_connection_server_id: ServerId,
|
||||
pub follower_connection_id: i32,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn leader_connection(&self) -> ConnectionId {
|
||||
ConnectionId {
|
||||
owner_id: self.leader_connection_server_id.0 as u32,
|
||||
id: self.leader_connection_id as u32,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn follower_connection(&self) -> ConnectionId {
|
||||
ConnectionId {
|
||||
owner_id: self.follower_connection_server_id.0 as u32,
|
||||
id: self.follower_connection_id as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::room::Entity",
|
||||
from = "Column::RoomId",
|
||||
to = "super::room::Column::Id"
|
||||
)]
|
||||
Room,
|
||||
}
|
||||
|
||||
impl Related<super::room::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Room.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
32
crates/collab/src/db/tables/language_server.rs
Normal file
32
crates/collab/src/db/tables/language_server.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use crate::db::ProjectId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "language_servers")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub project_id: ProjectId,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub capabilities: String,
|
||||
pub worktree_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::project::Entity",
|
||||
from = "Column::ProjectId",
|
||||
to = "super::project::Column::Id"
|
||||
)]
|
||||
Project,
|
||||
}
|
||||
|
||||
impl Related<super::project::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Project.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
29
crates/collab/src/db/tables/notification.rs
Normal file
29
crates/collab/src/db/tables/notification.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use crate::db::{NotificationId, NotificationKindId, UserId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
use time::PrimitiveDateTime;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "notifications")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: NotificationId,
|
||||
pub created_at: PrimitiveDateTime,
|
||||
pub recipient_id: UserId,
|
||||
pub kind: NotificationKindId,
|
||||
pub entity_id: Option<i32>,
|
||||
pub content: String,
|
||||
pub is_read: bool,
|
||||
pub response: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::RecipientId",
|
||||
to = "super::user::Column::Id"
|
||||
)]
|
||||
Recipient,
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
15
crates/collab/src/db/tables/notification_kind.rs
Normal file
15
crates/collab/src/db/tables/notification_kind.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use crate::db::NotificationKindId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "notification_kinds")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: NotificationKindId,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
43
crates/collab/src/db/tables/observed_buffer_edits.rs
Normal file
43
crates/collab/src/db/tables/observed_buffer_edits.rs
Normal file
@@ -0,0 +1,43 @@
|
||||
use crate::db::{BufferId, UserId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "observed_buffer_edits")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub user_id: UserId,
|
||||
pub buffer_id: BufferId,
|
||||
pub epoch: i32,
|
||||
pub lamport_timestamp: i32,
|
||||
pub replica_id: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::buffer::Entity",
|
||||
from = "Column::BufferId",
|
||||
to = "super::buffer::Column::Id"
|
||||
)]
|
||||
Buffer,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::buffer::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Buffer.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
94
crates/collab/src/db/tables/project.rs
Normal file
94
crates/collab/src/db/tables/project.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use crate::db::{ProjectId, Result, RoomId, ServerId, UserId};
|
||||
use anyhow::Context as _;
|
||||
use rpc::ConnectionId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "projects")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: ProjectId,
|
||||
pub room_id: Option<RoomId>,
|
||||
pub host_user_id: Option<UserId>,
|
||||
pub host_connection_id: Option<i32>,
|
||||
pub host_connection_server_id: Option<ServerId>,
|
||||
pub windows_paths: bool,
|
||||
pub features: String,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn host_connection(&self) -> Result<ConnectionId> {
|
||||
let host_connection_server_id = self
|
||||
.host_connection_server_id
|
||||
.context("empty host_connection_server_id")?;
|
||||
let host_connection_id = self
|
||||
.host_connection_id
|
||||
.context("empty host_connection_id")?;
|
||||
Ok(ConnectionId {
|
||||
owner_id: host_connection_server_id.0 as u32,
|
||||
id: host_connection_id as u32,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::HostUserId",
|
||||
to = "super::user::Column::Id"
|
||||
)]
|
||||
HostUser,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::room::Entity",
|
||||
from = "Column::RoomId",
|
||||
to = "super::room::Column::Id"
|
||||
)]
|
||||
Room,
|
||||
#[sea_orm(has_many = "super::worktree::Entity")]
|
||||
Worktrees,
|
||||
#[sea_orm(has_many = "super::project_repository::Entity")]
|
||||
Repositories,
|
||||
#[sea_orm(has_many = "super::project_collaborator::Entity")]
|
||||
Collaborators,
|
||||
#[sea_orm(has_many = "super::language_server::Entity")]
|
||||
LanguageServers,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::HostUser.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::room::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Room.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::worktree::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Worktrees.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::project_repository::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Repositories.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::project_collaborator::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Collaborators.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::language_server::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::LanguageServers.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
45
crates/collab/src/db/tables/project_collaborator.rs
Normal file
45
crates/collab/src/db/tables/project_collaborator.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use crate::db::{ProjectCollaboratorId, ProjectId, ReplicaId, ServerId, UserId};
|
||||
use rpc::ConnectionId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "project_collaborators")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: ProjectCollaboratorId,
|
||||
pub project_id: ProjectId,
|
||||
pub connection_id: i32,
|
||||
pub connection_server_id: ServerId,
|
||||
pub user_id: UserId,
|
||||
pub replica_id: ReplicaId,
|
||||
pub is_host: bool,
|
||||
pub committer_name: Option<String>,
|
||||
pub committer_email: Option<String>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn connection(&self) -> ConnectionId {
|
||||
ConnectionId {
|
||||
owner_id: self.connection_server_id.0 as u32,
|
||||
id: self.connection_id as u32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::project::Entity",
|
||||
from = "Column::ProjectId",
|
||||
to = "super::project::Column::Id"
|
||||
)]
|
||||
Project,
|
||||
}
|
||||
|
||||
impl Related<super::project::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Project.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
49
crates/collab/src/db/tables/project_repository.rs
Normal file
49
crates/collab/src/db/tables/project_repository.rs
Normal file
@@ -0,0 +1,49 @@
|
||||
use crate::db::ProjectId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "project_repositories")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub project_id: ProjectId,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i64,
|
||||
pub abs_path: String,
|
||||
pub legacy_worktree_id: Option<i64>,
|
||||
// JSON array containing 1 or more integer project entry ids
|
||||
pub entry_ids: String,
|
||||
pub scan_id: i64,
|
||||
pub is_deleted: bool,
|
||||
// JSON array typed string
|
||||
pub current_merge_conflicts: Option<String>,
|
||||
// The suggested merge commit message
|
||||
pub merge_message: Option<String>,
|
||||
// A JSON object representing the current Branch values
|
||||
pub branch_summary: Option<String>,
|
||||
// A JSON object representing the current Head commit values
|
||||
pub head_commit_details: Option<String>,
|
||||
pub remote_upstream_url: Option<String>,
|
||||
pub remote_origin_url: Option<String>,
|
||||
pub repository_dir_abs_path: Option<String>,
|
||||
pub common_dir_abs_path: Option<String>,
|
||||
// JSON array of linked worktree objects
|
||||
pub linked_worktrees: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::project::Entity",
|
||||
from = "Column::ProjectId",
|
||||
to = "super::project::Column::Id"
|
||||
)]
|
||||
Project,
|
||||
}
|
||||
|
||||
impl Related<super::project::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Project.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
38
crates/collab/src/db/tables/project_repository_statuses.rs
Normal file
38
crates/collab/src/db/tables/project_repository_statuses.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use crate::db::ProjectId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "project_repository_statuses")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub project_id: ProjectId,
|
||||
#[sea_orm(primary_key)]
|
||||
pub repository_id: i64,
|
||||
#[sea_orm(primary_key)]
|
||||
pub repo_path: String,
|
||||
/// Old single-code status field, no longer used but kept here to mirror the DB schema.
|
||||
pub status: i64,
|
||||
pub status_kind: StatusKind,
|
||||
/// For unmerged entries, this is the `first_head` status. For tracked entries, this is the `index_status`.
|
||||
pub first_status: Option<i32>,
|
||||
/// For unmerged entries, this is the `second_head` status. For tracked entries, this is the `worktree_status`.
|
||||
pub second_status: Option<i32>,
|
||||
pub lines_added: Option<i32>,
|
||||
pub lines_deleted: Option<i32>,
|
||||
pub scan_id: i64,
|
||||
pub is_deleted: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum)]
|
||||
#[sea_orm(rs_type = "i32", db_type = "Integer")]
|
||||
pub enum StatusKind {
|
||||
Untracked = 0,
|
||||
Ignored = 1,
|
||||
Unmerged = 2,
|
||||
Tracked = 3,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
53
crates/collab/src/db/tables/room.rs
Normal file
53
crates/collab/src/db/tables/room.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
use crate::db::{ChannelId, RoomId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Default, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "rooms")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: RoomId,
|
||||
pub live_kit_room: String,
|
||||
pub channel_id: Option<ChannelId>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_many = "super::room_participant::Entity")]
|
||||
RoomParticipant,
|
||||
#[sea_orm(has_many = "super::project::Entity")]
|
||||
Project,
|
||||
#[sea_orm(has_many = "super::follower::Entity")]
|
||||
Follower,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::channel::Entity",
|
||||
from = "Column::ChannelId",
|
||||
to = "super::channel::Column::Id"
|
||||
)]
|
||||
Channel,
|
||||
}
|
||||
|
||||
impl Related<super::room_participant::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::RoomParticipant.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::project::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Project.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::follower::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Follower.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::channel::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Channel.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
62
crates/collab/src/db/tables/room_participant.rs
Normal file
62
crates/collab/src/db/tables/room_participant.rs
Normal file
@@ -0,0 +1,62 @@
|
||||
use crate::db::{ChannelRole, ProjectId, RoomId, RoomParticipantId, ServerId, UserId};
|
||||
use rpc::ConnectionId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "room_participants")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: RoomParticipantId,
|
||||
pub room_id: RoomId,
|
||||
pub user_id: UserId,
|
||||
pub answering_connection_id: Option<i32>,
|
||||
pub answering_connection_server_id: Option<ServerId>,
|
||||
pub answering_connection_lost: bool,
|
||||
pub location_kind: Option<i32>,
|
||||
pub location_project_id: Option<ProjectId>,
|
||||
pub initial_project_id: Option<ProjectId>,
|
||||
pub calling_user_id: UserId,
|
||||
pub calling_connection_id: i32,
|
||||
pub calling_connection_server_id: Option<ServerId>,
|
||||
pub participant_index: Option<i32>,
|
||||
pub role: Option<ChannelRole>,
|
||||
}
|
||||
|
||||
impl Model {
|
||||
pub fn answering_connection(&self) -> Option<ConnectionId> {
|
||||
Some(ConnectionId {
|
||||
owner_id: self.answering_connection_server_id?.0 as u32,
|
||||
id: self.answering_connection_id? as u32,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id"
|
||||
)]
|
||||
User,
|
||||
#[sea_orm(
|
||||
belongs_to = "super::room::Entity",
|
||||
from = "Column::RoomId",
|
||||
to = "super::room::Column::Id"
|
||||
)]
|
||||
Room,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::room::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Room.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
15
crates/collab/src/db/tables/server.rs
Normal file
15
crates/collab/src/db/tables/server.rs
Normal file
@@ -0,0 +1,15 @@
|
||||
use crate::db::ServerId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "servers")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: ServerId,
|
||||
pub environment: String,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
32
crates/collab/src/db/tables/shared_thread.rs
Normal file
32
crates/collab/src/db/tables/shared_thread.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use crate::db::{SharedThreadId, UserId};
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "shared_threads")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key, auto_increment = false)]
|
||||
pub id: SharedThreadId,
|
||||
pub user_id: UserId,
|
||||
pub title: String,
|
||||
pub data: Vec<u8>,
|
||||
pub created_at: DateTime,
|
||||
pub updated_at: DateTime,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::user::Entity",
|
||||
from = "Column::UserId",
|
||||
to = "super::user::Column::Id"
|
||||
)]
|
||||
User,
|
||||
}
|
||||
|
||||
impl Related<super::user::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::User.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
50
crates/collab/src/db/tables/user.rs
Normal file
50
crates/collab/src/db/tables/user.rs
Normal file
@@ -0,0 +1,50 @@
|
||||
use crate::db::UserId;
|
||||
use chrono::NaiveDateTime;
|
||||
use sea_orm::entity::prelude::*;
|
||||
use serde::Serialize;
|
||||
|
||||
/// A user model.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, DeriveEntityModel, Serialize)]
|
||||
#[sea_orm(table_name = "users")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: UserId,
|
||||
pub github_login: String,
|
||||
pub github_user_id: i32,
|
||||
pub github_user_created_at: Option<NaiveDateTime>,
|
||||
pub email_address: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub admin: bool,
|
||||
pub connected_once: bool,
|
||||
pub created_at: NaiveDateTime,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(has_one = "super::room_participant::Entity")]
|
||||
RoomParticipant,
|
||||
#[sea_orm(has_many = "super::project::Entity")]
|
||||
HostedProjects,
|
||||
#[sea_orm(has_many = "super::channel_member::Entity")]
|
||||
ChannelMemberships,
|
||||
}
|
||||
|
||||
impl Related<super::room_participant::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::RoomParticipant.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::project::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::HostedProjects.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl Related<super::channel_member::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ChannelMemberships.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
37
crates/collab/src/db/tables/worktree.rs
Normal file
37
crates/collab/src/db/tables/worktree.rs
Normal file
@@ -0,0 +1,37 @@
|
||||
use crate::db::ProjectId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "worktrees")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i64,
|
||||
#[sea_orm(primary_key)]
|
||||
pub project_id: ProjectId,
|
||||
pub abs_path: String,
|
||||
pub root_name: String,
|
||||
pub visible: bool,
|
||||
/// The last scan for which we've observed entries. It may be in progress.
|
||||
pub scan_id: i64,
|
||||
/// The last scan that fully completed.
|
||||
pub completed_scan_id: i64,
|
||||
pub root_repo_common_dir: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {
|
||||
#[sea_orm(
|
||||
belongs_to = "super::project::Entity",
|
||||
from = "Column::ProjectId",
|
||||
to = "super::project::Column::Id"
|
||||
)]
|
||||
Project,
|
||||
}
|
||||
|
||||
impl Related<super::project::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::Project.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
21
crates/collab/src/db/tables/worktree_diagnostic_summary.rs
Normal file
21
crates/collab/src/db/tables/worktree_diagnostic_summary.rs
Normal file
@@ -0,0 +1,21 @@
|
||||
use crate::db::ProjectId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "worktree_diagnostic_summaries")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub project_id: ProjectId,
|
||||
#[sea_orm(primary_key)]
|
||||
pub worktree_id: i64,
|
||||
#[sea_orm(primary_key)]
|
||||
pub path: String,
|
||||
pub language_server_id: i64,
|
||||
pub error_count: i32,
|
||||
pub warning_count: i32,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
31
crates/collab/src/db/tables/worktree_entry.rs
Normal file
31
crates/collab/src/db/tables/worktree_entry.rs
Normal file
@@ -0,0 +1,31 @@
|
||||
use crate::db::ProjectId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "worktree_entries")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub project_id: ProjectId,
|
||||
#[sea_orm(primary_key)]
|
||||
pub worktree_id: i64,
|
||||
#[sea_orm(primary_key)]
|
||||
pub id: i64,
|
||||
pub is_dir: bool,
|
||||
pub path: String,
|
||||
pub inode: i64,
|
||||
pub mtime_seconds: i64,
|
||||
pub mtime_nanos: i32,
|
||||
pub git_status: Option<i64>,
|
||||
pub is_ignored: bool,
|
||||
pub is_external: bool,
|
||||
pub is_deleted: bool,
|
||||
pub is_hidden: bool,
|
||||
pub scan_id: i64,
|
||||
pub is_fifo: bool,
|
||||
pub canonical_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
38
crates/collab/src/db/tables/worktree_settings_file.rs
Normal file
38
crates/collab/src/db/tables/worktree_settings_file.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use crate::db::ProjectId;
|
||||
use sea_orm::entity::prelude::*;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)]
|
||||
#[sea_orm(table_name = "worktree_settings_files")]
|
||||
pub struct Model {
|
||||
#[sea_orm(primary_key)]
|
||||
pub project_id: ProjectId,
|
||||
#[sea_orm(primary_key)]
|
||||
pub worktree_id: i64,
|
||||
#[sea_orm(primary_key)]
|
||||
pub path: String,
|
||||
pub content: String,
|
||||
pub kind: LocalSettingsKind,
|
||||
pub outside_worktree: bool,
|
||||
}
|
||||
|
||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||
pub enum Relation {}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
|
||||
#[derive(
|
||||
Copy, Clone, Debug, PartialEq, Eq, EnumIter, DeriveActiveEnum, Default, Hash, serde::Serialize,
|
||||
)]
|
||||
#[sea_orm(rs_type = "String", db_type = "String(StringLen::None)")]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum LocalSettingsKind {
|
||||
#[default]
|
||||
#[sea_orm(string_value = "settings")]
|
||||
Settings,
|
||||
#[sea_orm(string_value = "tasks")]
|
||||
Tasks,
|
||||
#[sea_orm(string_value = "editorconfig")]
|
||||
Editorconfig,
|
||||
#[sea_orm(string_value = "debug")]
|
||||
Debug,
|
||||
}
|
||||
3
crates/collab/src/entities.rs
Normal file
3
crates/collab/src/entities.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod user;
|
||||
|
||||
pub use user::*;
|
||||
11
crates/collab/src/entities/user.rs
Normal file
11
crates/collab/src/entities/user.rs
Normal file
@@ -0,0 +1,11 @@
|
||||
use crate::db::UserId;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
pub id: UserId,
|
||||
pub github_login: String,
|
||||
pub avatar_url: String,
|
||||
pub name: Option<String>,
|
||||
pub admin: bool,
|
||||
pub connected_once: bool,
|
||||
}
|
||||
41
crates/collab/src/env.rs
Normal file
41
crates/collab/src/env.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use anyhow::{Context as _, Result};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
pub fn get_dotenv_vars(current_dir: impl AsRef<Path>) -> Result<Vec<(String, String)>> {
|
||||
let current_dir = current_dir.as_ref();
|
||||
|
||||
let mut vars = Vec::new();
|
||||
let env_content =
|
||||
fs::read_to_string(current_dir.join(".env.toml")).context("no .env.toml file found")?;
|
||||
|
||||
add_vars(env_content, &mut vars)?;
|
||||
|
||||
if let Ok(secret_content) = fs::read_to_string(current_dir.join(".env.secret.toml")) {
|
||||
add_vars(secret_content, &mut vars)?;
|
||||
}
|
||||
|
||||
Ok(vars)
|
||||
}
|
||||
|
||||
pub fn load_dotenv() -> Result<()> {
|
||||
for (key, value) in get_dotenv_vars("./crates/collab")? {
|
||||
unsafe { std::env::set_var(key, value) };
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn add_vars(env_content: String, vars: &mut Vec<(String, String)>) -> Result<()> {
|
||||
let env: toml::map::Map<String, toml::Value> = toml::de::from_str(&env_content)?;
|
||||
for (key, value) in env {
|
||||
let value = match value {
|
||||
toml::Value::String(value) => value,
|
||||
toml::Value::Integer(value) => value.to_string(),
|
||||
toml::Value::Float(value) => value.to_string(),
|
||||
toml::Value::Boolean(value) => value.to_string(),
|
||||
_ => panic!("unsupported TOML value in .env.toml for key {}", key),
|
||||
};
|
||||
vars.push((key, value));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
29
crates/collab/src/errors.rs
Normal file
29
crates/collab/src/errors.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
// Allow tide Results to accept context like other Results do when
|
||||
// using anyhow.
|
||||
pub trait TideResultExt {
|
||||
fn context<C>(self, cx: C) -> Self
|
||||
where
|
||||
C: std::fmt::Display + Send + Sync + 'static;
|
||||
|
||||
fn with_context<C, F>(self, f: F) -> Self
|
||||
where
|
||||
C: std::fmt::Display + Send + Sync + 'static,
|
||||
F: FnOnce() -> C;
|
||||
}
|
||||
|
||||
impl<T> TideResultExt for tide::Result<T> {
|
||||
fn context<C>(self, cx: C) -> Self
|
||||
where
|
||||
C: std::fmt::Display + Send + Sync + 'static,
|
||||
{
|
||||
self.map_err(|e| tide::Error::new(e.status(), e.into_inner().context(cx)))
|
||||
}
|
||||
|
||||
fn with_context<C, F>(self, f: F) -> Self
|
||||
where
|
||||
C: std::fmt::Display + Send + Sync + 'static,
|
||||
F: FnOnce() -> C,
|
||||
{
|
||||
self.map_err(|e| tide::Error::new(e.status(), e.into_inner().context(f())))
|
||||
}
|
||||
}
|
||||
39
crates/collab/src/executor.rs
Normal file
39
crates/collab/src/executor.rs
Normal file
@@ -0,0 +1,39 @@
|
||||
use std::{future::Future, time::Duration};
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
use gpui::BackgroundExecutor;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum Executor {
|
||||
Production,
|
||||
#[cfg(feature = "test-support")]
|
||||
Deterministic(BackgroundExecutor),
|
||||
}
|
||||
|
||||
impl Executor {
|
||||
pub fn spawn_detached<F>(&self, future: F)
|
||||
where
|
||||
F: 'static + Send + Future<Output = ()>,
|
||||
{
|
||||
match self {
|
||||
Executor::Production => {
|
||||
tokio::spawn(future);
|
||||
}
|
||||
#[cfg(feature = "test-support")]
|
||||
Executor::Deterministic(background) => {
|
||||
background.spawn(future).detach();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sleep(&self, duration: Duration) -> impl Future<Output = ()> + use<> {
|
||||
let this = self.clone();
|
||||
async move {
|
||||
match this {
|
||||
Executor::Production => tokio::time::sleep(duration).await,
|
||||
#[cfg(feature = "test-support")]
|
||||
Executor::Deterministic(background) => background.timer(duration).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
339
crates/collab/src/lib.rs
Normal file
339
crates/collab/src/lib.rs
Normal file
@@ -0,0 +1,339 @@
|
||||
pub mod api;
|
||||
pub mod auth;
|
||||
pub mod db;
|
||||
pub mod entities;
|
||||
pub mod env;
|
||||
pub mod executor;
|
||||
pub mod rpc;
|
||||
pub mod seed;
|
||||
pub mod services;
|
||||
|
||||
use anyhow::Context as _;
|
||||
use aws_config::{BehaviorVersion, Region};
|
||||
use axum::{
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::IntoResponse,
|
||||
};
|
||||
use db::Database;
|
||||
use executor::Executor;
|
||||
use serde::Deserialize;
|
||||
use std::{path::PathBuf, sync::Arc};
|
||||
use util::ResultExt;
|
||||
|
||||
use crate::services::{CloudUserService, UserService};
|
||||
|
||||
pub const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
pub const REVISION: Option<&'static str> = option_env!("GITHUB_SHA");
|
||||
|
||||
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||
|
||||
pub enum Error {
|
||||
Http(StatusCode, String, HeaderMap),
|
||||
Database(sea_orm::error::DbErr),
|
||||
Internal(anyhow::Error),
|
||||
}
|
||||
|
||||
impl From<anyhow::Error> for Error {
|
||||
fn from(error: anyhow::Error) -> Self {
|
||||
Self::Internal(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<sea_orm::error::DbErr> for Error {
|
||||
fn from(error: sea_orm::error::DbErr) -> Self {
|
||||
Self::Database(error)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<axum::Error> for Error {
|
||||
fn from(error: axum::Error) -> Self {
|
||||
Self::Internal(error.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<axum::http::Error> for Error {
|
||||
fn from(error: axum::http::Error) -> Self {
|
||||
Self::Internal(error.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for Error {
|
||||
fn from(error: serde_json::Error) -> Self {
|
||||
Self::Internal(error.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl Error {
|
||||
fn http(code: StatusCode, message: String) -> Self {
|
||||
Self::Http(code, message, HeaderMap::default())
|
||||
}
|
||||
}
|
||||
|
||||
impl IntoResponse for Error {
|
||||
fn into_response(self) -> axum::response::Response {
|
||||
match self {
|
||||
Error::Http(code, message, headers) => {
|
||||
log::error!("HTTP error {}: {}", code, &message);
|
||||
(code, headers, message).into_response()
|
||||
}
|
||||
Error::Database(error) => {
|
||||
log::error!(
|
||||
"HTTP error {}: {:?}",
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&error
|
||||
);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("{}", &error)).into_response()
|
||||
}
|
||||
Error::Internal(error) => {
|
||||
log::error!(
|
||||
"HTTP error {}: {:?}",
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
&error
|
||||
);
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, format!("{}", &error)).into_response()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::Http(code, message, _headers) => (code, message).fmt(f),
|
||||
Error::Database(error) => error.fmt(f),
|
||||
Error::Internal(error) => error.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Error {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Error::Http(code, message, _) => write!(f, "{code}: {message}"),
|
||||
Error::Database(error) => error.fmt(f),
|
||||
Error::Internal(error) => error.fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for Error {}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct Config {
|
||||
pub http_port: u16,
|
||||
pub database_url: String,
|
||||
pub seed_path: Option<PathBuf>,
|
||||
pub database_max_connections: u32,
|
||||
pub livekit_server: Option<String>,
|
||||
pub livekit_key: Option<String>,
|
||||
pub livekit_secret: Option<String>,
|
||||
pub rust_log: Option<String>,
|
||||
pub log_json: Option<bool>,
|
||||
pub blob_store_url: Option<String>,
|
||||
pub blob_store_region: Option<String>,
|
||||
pub blob_store_access_key: Option<String>,
|
||||
pub blob_store_secret_key: Option<String>,
|
||||
pub blob_store_bucket: Option<String>,
|
||||
pub kinesis_region: Option<String>,
|
||||
pub kinesis_stream: Option<String>,
|
||||
pub kinesis_access_key: Option<String>,
|
||||
pub kinesis_secret_key: Option<String>,
|
||||
pub zed_environment: Arc<str>,
|
||||
pub zed_cloud_internal_api_key: String,
|
||||
pub zed_client_checksum_seed: Option<String>,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
pub fn is_development(&self) -> bool {
|
||||
self.zed_environment == "development".into()
|
||||
}
|
||||
|
||||
/// Returns the base `zed.dev` URL.
|
||||
pub fn zed_dot_dev_url(&self) -> &str {
|
||||
match self.zed_environment.as_ref() {
|
||||
"development" => "http://localhost:3000",
|
||||
"staging" => "https://staging.zed.dev",
|
||||
_ => "https://zed.dev",
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the base Zed Cloud URL.
|
||||
pub fn zed_cloud_url(&self) -> &str {
|
||||
match self.zed_environment.as_ref() {
|
||||
"development" => "http://localhost:8787",
|
||||
_ => "https://cloud.zed.dev",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn test() -> Self {
|
||||
Self {
|
||||
http_port: 0,
|
||||
database_url: "".into(),
|
||||
database_max_connections: 0,
|
||||
livekit_server: None,
|
||||
livekit_key: None,
|
||||
livekit_secret: None,
|
||||
rust_log: None,
|
||||
log_json: None,
|
||||
zed_environment: "test".into(),
|
||||
zed_cloud_internal_api_key: "test-internal-api-key".into(),
|
||||
blob_store_url: None,
|
||||
blob_store_region: None,
|
||||
blob_store_access_key: None,
|
||||
blob_store_secret_key: None,
|
||||
blob_store_bucket: None,
|
||||
zed_client_checksum_seed: None,
|
||||
seed_path: None,
|
||||
kinesis_region: None,
|
||||
kinesis_access_key: None,
|
||||
kinesis_secret_key: None,
|
||||
kinesis_stream: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The service mode that collab should run in.
|
||||
#[derive(Debug, PartialEq, Eq, Clone, Copy, strum::Display)]
|
||||
#[strum(serialize_all = "snake_case")]
|
||||
pub enum ServiceMode {
|
||||
Api,
|
||||
Collab,
|
||||
All,
|
||||
}
|
||||
|
||||
impl ServiceMode {
|
||||
pub fn is_collab(&self) -> bool {
|
||||
matches!(self, Self::Collab | Self::All)
|
||||
}
|
||||
|
||||
pub fn is_api(&self) -> bool {
|
||||
matches!(self, Self::Api | Self::All)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct AppState {
|
||||
pub db: Arc<Database>,
|
||||
pub http_client: Option<reqwest::Client>,
|
||||
pub livekit_client: Option<Arc<dyn livekit_api::Client>>,
|
||||
pub blob_store_client: Option<aws_sdk_s3::Client>,
|
||||
pub executor: Executor,
|
||||
pub kinesis_client: Option<::aws_sdk_kinesis::Client>,
|
||||
pub user_service: Arc<dyn UserService>,
|
||||
pub config: Config,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub async fn new(config: Config, executor: Executor) -> Result<Arc<Self>> {
|
||||
let mut db_options = db::ConnectOptions::new(config.database_url.clone());
|
||||
db_options.max_connections(config.database_max_connections);
|
||||
let mut db = Database::new(db_options).await?;
|
||||
db.initialize_notification_kinds().await?;
|
||||
|
||||
let livekit_client = if let Some(((server, key), secret)) = config
|
||||
.livekit_server
|
||||
.as_ref()
|
||||
.zip(config.livekit_key.as_ref())
|
||||
.zip(config.livekit_secret.as_ref())
|
||||
{
|
||||
Some(Arc::new(livekit_api::LiveKitClient::new(
|
||||
server.clone(),
|
||||
key.clone(),
|
||||
secret.clone(),
|
||||
)) as Arc<dyn livekit_api::Client>)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let user_agent = format!("Collab/{VERSION} ({})", REVISION.unwrap_or("unknown"));
|
||||
let http_client = reqwest::Client::builder()
|
||||
.user_agent(user_agent)
|
||||
.build()
|
||||
.context("failed to construct HTTP client")?;
|
||||
|
||||
let db = Arc::new(db);
|
||||
let this = Self {
|
||||
db: db.clone(),
|
||||
http_client: Some(http_client.clone()),
|
||||
livekit_client,
|
||||
blob_store_client: build_blob_store_client(&config).await.log_err(),
|
||||
executor,
|
||||
kinesis_client: if config.kinesis_access_key.is_some() {
|
||||
build_kinesis_client(&config).await.log_err()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
user_service: Arc::new(CloudUserService::new(
|
||||
http_client,
|
||||
config.zed_cloud_url().to_string(),
|
||||
config.zed_cloud_internal_api_key.clone(),
|
||||
)),
|
||||
config,
|
||||
};
|
||||
Ok(Arc::new(this))
|
||||
}
|
||||
}
|
||||
|
||||
async fn build_blob_store_client(config: &Config) -> anyhow::Result<aws_sdk_s3::Client> {
|
||||
let keys = aws_sdk_s3::config::Credentials::new(
|
||||
config
|
||||
.blob_store_access_key
|
||||
.clone()
|
||||
.context("missing blob_store_access_key")?,
|
||||
config
|
||||
.blob_store_secret_key
|
||||
.clone()
|
||||
.context("missing blob_store_secret_key")?,
|
||||
None,
|
||||
None,
|
||||
"env",
|
||||
);
|
||||
|
||||
let s3_config = aws_config::defaults(BehaviorVersion::latest())
|
||||
.endpoint_url(
|
||||
config
|
||||
.blob_store_url
|
||||
.as_ref()
|
||||
.context("missing blob_store_url")?,
|
||||
)
|
||||
.region(Region::new(
|
||||
config
|
||||
.blob_store_region
|
||||
.clone()
|
||||
.context("missing blob_store_region")?,
|
||||
))
|
||||
.credentials_provider(keys)
|
||||
.load()
|
||||
.await;
|
||||
|
||||
Ok(aws_sdk_s3::Client::new(&s3_config))
|
||||
}
|
||||
|
||||
async fn build_kinesis_client(config: &Config) -> anyhow::Result<aws_sdk_kinesis::Client> {
|
||||
let keys = aws_sdk_s3::config::Credentials::new(
|
||||
config
|
||||
.kinesis_access_key
|
||||
.clone()
|
||||
.context("missing kinesis_access_key")?,
|
||||
config
|
||||
.kinesis_secret_key
|
||||
.clone()
|
||||
.context("missing kinesis_secret_key")?,
|
||||
None,
|
||||
None,
|
||||
"env",
|
||||
);
|
||||
|
||||
let kinesis_config = aws_config::defaults(BehaviorVersion::latest())
|
||||
.region(Region::new(
|
||||
config
|
||||
.kinesis_region
|
||||
.clone()
|
||||
.context("missing kinesis_region")?,
|
||||
))
|
||||
.credentials_provider(keys)
|
||||
.load()
|
||||
.await;
|
||||
|
||||
Ok(aws_sdk_kinesis::Client::new(&kinesis_config))
|
||||
}
|
||||
268
crates/collab/src/main.rs
Normal file
268
crates/collab/src/main.rs
Normal file
@@ -0,0 +1,268 @@
|
||||
use anyhow::anyhow;
|
||||
use axum::headers::HeaderMapExt;
|
||||
use axum::{
|
||||
Extension, Router,
|
||||
extract::MatchedPath,
|
||||
http::{Request, Response},
|
||||
routing::get,
|
||||
};
|
||||
|
||||
use collab::api::CloudflareIpCountryHeader;
|
||||
use collab::{
|
||||
AppState, Config, Result, api::fetch_extensions_from_blob_store_periodically, db, env,
|
||||
executor::Executor,
|
||||
};
|
||||
use collab::{REVISION, ServiceMode, VERSION};
|
||||
use db::Database;
|
||||
use std::{
|
||||
env::args,
|
||||
net::{SocketAddr, TcpListener},
|
||||
sync::Arc,
|
||||
time::Duration,
|
||||
};
|
||||
#[cfg(unix)]
|
||||
use tokio::signal::unix::SignalKind;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing_subscriber::{
|
||||
Layer, filter::EnvFilter, fmt::format::JsonFields, util::SubscriberInitExt,
|
||||
};
|
||||
use util::ResultExt as _;
|
||||
|
||||
#[expect(clippy::result_large_err)]
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
if let Err(error) = env::load_dotenv() {
|
||||
eprintln!(
|
||||
"error loading .env.toml (this is expected in production): {}",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
let mut args = args().skip(1);
|
||||
match args.next().as_deref() {
|
||||
Some("version") => {
|
||||
println!("collab v{} ({})", VERSION, REVISION.unwrap_or("unknown"));
|
||||
}
|
||||
Some("seed") => {
|
||||
let config = envy::from_env::<Config>().expect("error loading config");
|
||||
let db_options = db::ConnectOptions::new(config.database_url.clone());
|
||||
|
||||
let mut db = Database::new(db_options).await?;
|
||||
db.initialize_notification_kinds().await?;
|
||||
|
||||
collab::seed::seed(&config, &db, false).await?;
|
||||
}
|
||||
Some("serve") => {
|
||||
let mode = match args.next().as_deref() {
|
||||
Some("collab") => ServiceMode::Collab,
|
||||
Some("api") => ServiceMode::Api,
|
||||
Some("all") => ServiceMode::All,
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"usage: collab <version | seed | serve <api|collab|all>>"
|
||||
))?;
|
||||
}
|
||||
};
|
||||
|
||||
let config = envy::from_env::<Config>().expect("error loading config");
|
||||
init_tracing(&config);
|
||||
init_panic_hook();
|
||||
|
||||
let mut app = Router::new()
|
||||
.route("/", get(handle_root))
|
||||
.route("/healthz", get(handle_liveness_probe))
|
||||
.layer(Extension(mode));
|
||||
|
||||
let listener = TcpListener::bind(format!("0.0.0.0:{}", config.http_port))
|
||||
.expect("failed to bind TCP listener");
|
||||
|
||||
let mut on_shutdown = None;
|
||||
|
||||
if mode.is_collab() || mode.is_api() {
|
||||
setup_app_database(&config).await?;
|
||||
|
||||
let state = AppState::new(config, Executor::Production).await?;
|
||||
|
||||
if mode.is_collab() {
|
||||
let epoch = state
|
||||
.db
|
||||
.create_server(&state.config.zed_environment)
|
||||
.await?;
|
||||
let rpc_server = collab::rpc::Server::new(epoch, state.clone());
|
||||
rpc_server.start().await?;
|
||||
|
||||
app = app.merge(collab::rpc::routes(rpc_server.clone()));
|
||||
|
||||
on_shutdown = Some(Box::new(move || rpc_server.teardown()));
|
||||
}
|
||||
|
||||
if mode.is_api() {
|
||||
fetch_extensions_from_blob_store_periodically(state.clone());
|
||||
|
||||
app = app
|
||||
.merge(collab::api::events::router())
|
||||
.merge(collab::api::extensions::router())
|
||||
}
|
||||
|
||||
app = app.layer(Extension(state.clone()));
|
||||
}
|
||||
|
||||
app = app.layer(
|
||||
TraceLayer::new_for_http()
|
||||
.make_span_with(|request: &Request<_>| {
|
||||
let matched_path = request
|
||||
.extensions()
|
||||
.get::<MatchedPath>()
|
||||
.map(MatchedPath::as_str);
|
||||
|
||||
let geoip_country_code = request
|
||||
.headers()
|
||||
.typed_get::<CloudflareIpCountryHeader>()
|
||||
.map(|header| header.to_string());
|
||||
|
||||
tracing::info_span!(
|
||||
"http_request",
|
||||
method = ?request.method(),
|
||||
matched_path,
|
||||
geoip_country_code,
|
||||
user_id = tracing::field::Empty,
|
||||
login = tracing::field::Empty,
|
||||
authn.jti = tracing::field::Empty,
|
||||
is_staff = tracing::field::Empty
|
||||
)
|
||||
})
|
||||
.on_response(
|
||||
|response: &Response<_>, latency: Duration, _: &tracing::Span| {
|
||||
let duration_ms = latency.as_micros() as f64 / 1000.;
|
||||
tracing::info!(
|
||||
duration_ms,
|
||||
status = response.status().as_u16(),
|
||||
"finished processing request"
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
#[cfg(unix)]
|
||||
let signal = async move {
|
||||
let mut sigterm = tokio::signal::unix::signal(SignalKind::terminate())
|
||||
.expect("failed to listen for interrupt signal");
|
||||
let mut sigint = tokio::signal::unix::signal(SignalKind::interrupt())
|
||||
.expect("failed to listen for interrupt signal");
|
||||
let sigterm = sigterm.recv();
|
||||
let sigint = sigint.recv();
|
||||
futures::pin_mut!(sigterm, sigint);
|
||||
futures::future::select(sigterm, sigint).await;
|
||||
};
|
||||
|
||||
#[cfg(windows)]
|
||||
let signal = async move {
|
||||
// todo(windows):
|
||||
// `ctrl_close` does not work well, because tokio's signal handler always returns soon,
|
||||
// but system terminates the application soon after returning CTRL+CLOSE handler.
|
||||
// So we should implement blocking handler to treat CTRL+CLOSE signal.
|
||||
let mut ctrl_break = tokio::signal::windows::ctrl_break()
|
||||
.expect("failed to listen for interrupt signal");
|
||||
let mut ctrl_c = tokio::signal::windows::ctrl_c()
|
||||
.expect("failed to listen for interrupt signal");
|
||||
let ctrl_break = ctrl_break.recv();
|
||||
let ctrl_c = ctrl_c.recv();
|
||||
futures::pin_mut!(ctrl_break, ctrl_c);
|
||||
futures::future::select(ctrl_break, ctrl_c).await;
|
||||
};
|
||||
|
||||
axum::Server::from_tcp(listener)
|
||||
.map_err(|e| anyhow!(e))?
|
||||
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
||||
.with_graceful_shutdown(async move {
|
||||
signal.await;
|
||||
tracing::info!("Received interrupt signal");
|
||||
|
||||
if let Some(on_shutdown) = on_shutdown {
|
||||
on_shutdown();
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| anyhow!(e))?;
|
||||
}
|
||||
_ => {
|
||||
Err(anyhow!(
|
||||
"usage: collab <version | migrate | seed | serve <api|collab|llm|all>>"
|
||||
))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn setup_app_database(config: &Config) -> Result<()> {
|
||||
let db_options = db::ConnectOptions::new(config.database_url.clone());
|
||||
let mut db = Database::new(db_options).await?;
|
||||
|
||||
db.initialize_notification_kinds().await?;
|
||||
|
||||
if config.seed_path.is_some() {
|
||||
collab::seed::seed(config, &db, false).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_root(Extension(mode): Extension<ServiceMode>) -> String {
|
||||
format!("zed:{mode} v{VERSION} ({})", REVISION.unwrap_or("unknown"))
|
||||
}
|
||||
|
||||
async fn handle_liveness_probe(app_state: Option<Extension<Arc<AppState>>>) -> Result<String> {
|
||||
if let Some(state) = app_state {
|
||||
state.db.get_all_users(0, 1).await?;
|
||||
}
|
||||
|
||||
Ok("ok".to_string())
|
||||
}
|
||||
|
||||
pub fn init_tracing(config: &Config) -> Option<()> {
|
||||
use std::str::FromStr;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
|
||||
let filter = EnvFilter::from_str(config.rust_log.as_deref()?).log_err()?;
|
||||
|
||||
tracing_subscriber::registry()
|
||||
.with(if config.log_json.unwrap_or(false) {
|
||||
Box::new(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.fmt_fields(JsonFields::default())
|
||||
.event_format(
|
||||
tracing_subscriber::fmt::format()
|
||||
.json()
|
||||
.flatten_event(true)
|
||||
.with_span_list(false),
|
||||
)
|
||||
.with_filter(filter),
|
||||
) as Box<dyn Layer<_> + Send + Sync>
|
||||
} else {
|
||||
Box::new(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.event_format(tracing_subscriber::fmt::format().pretty())
|
||||
.with_filter(filter),
|
||||
)
|
||||
})
|
||||
.init();
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn init_panic_hook() {
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
let panic_message = match panic_info.payload().downcast_ref::<&'static str>() {
|
||||
Some(message) => *message,
|
||||
None => match panic_info.payload().downcast_ref::<String>() {
|
||||
Some(message) => message.as_str(),
|
||||
None => "Box<Any>",
|
||||
},
|
||||
};
|
||||
let backtrace = std::backtrace::Backtrace::force_capture();
|
||||
let location = panic_info
|
||||
.location()
|
||||
.map(|loc| format!("{}:{}", loc.file(), loc.line()));
|
||||
tracing::error!(panic = true, ?location, %panic_message, %backtrace, "Server Panic");
|
||||
}));
|
||||
}
|
||||
4234
crates/collab/src/rpc.rs
Normal file
4234
crates/collab/src/rpc.rs
Normal file
File diff suppressed because it is too large
Load Diff
253
crates/collab/src/rpc/connection_pool.rs
Normal file
253
crates/collab/src/rpc/connection_pool.rs
Normal file
@@ -0,0 +1,253 @@
|
||||
use crate::db::{ChannelId, ChannelRole, UserId};
|
||||
use anyhow::{Context as _, Result};
|
||||
use collections::{BTreeMap, HashMap, HashSet};
|
||||
use rpc::ConnectionId;
|
||||
use semver::Version;
|
||||
use serde::Serialize;
|
||||
use std::fmt;
|
||||
use tracing::instrument;
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
pub struct ConnectionPool {
|
||||
connections: BTreeMap<ConnectionId, Connection>,
|
||||
connected_users: BTreeMap<UserId, ConnectedPrincipal>,
|
||||
channels: ChannelPool,
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
struct ConnectedPrincipal {
|
||||
connection_ids: HashSet<ConnectionId>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialOrd, PartialEq, Eq, Ord)]
|
||||
pub struct ZedVersion(pub Version);
|
||||
|
||||
impl fmt::Display for ZedVersion {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl ZedVersion {
|
||||
pub fn can_collaborate(&self) -> bool {
|
||||
// v0.204.1 was the first version after the auto-update bug.
|
||||
// We reject any clients older than that to hope we can persuade them to upgrade.
|
||||
if self.0 < Version::new(0, 204, 1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Connection {
|
||||
pub user_id: UserId,
|
||||
pub admin: bool,
|
||||
pub zed_version: ZedVersion,
|
||||
}
|
||||
|
||||
impl ConnectionPool {
|
||||
pub fn reset(&mut self) {
|
||||
self.connections.clear();
|
||||
self.connected_users.clear();
|
||||
self.channels.clear();
|
||||
}
|
||||
|
||||
pub fn connection(&mut self, connection_id: ConnectionId) -> Option<&Connection> {
|
||||
self.connections.get(&connection_id)
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub fn add_connection(
|
||||
&mut self,
|
||||
connection_id: ConnectionId,
|
||||
user_id: UserId,
|
||||
admin: bool,
|
||||
zed_version: ZedVersion,
|
||||
) {
|
||||
self.connections.insert(
|
||||
connection_id,
|
||||
Connection {
|
||||
user_id,
|
||||
admin,
|
||||
zed_version,
|
||||
},
|
||||
);
|
||||
let connected_user = self.connected_users.entry(user_id).or_default();
|
||||
connected_user.connection_ids.insert(connection_id);
|
||||
}
|
||||
|
||||
#[instrument(skip(self))]
|
||||
pub fn remove_connection(&mut self, connection_id: ConnectionId) -> Result<()> {
|
||||
let connection = self
|
||||
.connections
|
||||
.get_mut(&connection_id)
|
||||
.context("no such connection")?;
|
||||
|
||||
let user_id = connection.user_id;
|
||||
|
||||
let connected_user = self.connected_users.get_mut(&user_id).unwrap();
|
||||
connected_user.connection_ids.remove(&connection_id);
|
||||
if connected_user.connection_ids.is_empty() {
|
||||
self.connected_users.remove(&user_id);
|
||||
self.channels.remove_user(&user_id);
|
||||
};
|
||||
self.connections.remove(&connection_id).unwrap();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn connections(&self) -> impl Iterator<Item = &Connection> {
|
||||
self.connections.values()
|
||||
}
|
||||
|
||||
pub fn user_connections(&self, user_id: UserId) -> impl Iterator<Item = &Connection> + '_ {
|
||||
self.connected_users
|
||||
.get(&user_id)
|
||||
.into_iter()
|
||||
.flat_map(|state| {
|
||||
state
|
||||
.connection_ids
|
||||
.iter()
|
||||
.flat_map(|cid| self.connections.get(cid))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn user_connection_ids(&self, user_id: UserId) -> impl Iterator<Item = ConnectionId> + '_ {
|
||||
self.connected_users
|
||||
.get(&user_id)
|
||||
.into_iter()
|
||||
.flat_map(|state| &state.connection_ids)
|
||||
.copied()
|
||||
}
|
||||
|
||||
pub fn channel_user_ids(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
) -> impl Iterator<Item = (UserId, ChannelRole)> + '_ {
|
||||
self.channels.users_to_notify(channel_id)
|
||||
}
|
||||
|
||||
pub fn channel_connection_ids(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
) -> impl Iterator<Item = (ConnectionId, ChannelRole)> + '_ {
|
||||
self.channels
|
||||
.users_to_notify(channel_id)
|
||||
.flat_map(|(user_id, role)| {
|
||||
self.user_connection_ids(user_id)
|
||||
.map(move |connection_id| (connection_id, role))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn subscribe_to_channel(
|
||||
&mut self,
|
||||
user_id: UserId,
|
||||
channel_id: ChannelId,
|
||||
role: ChannelRole,
|
||||
) {
|
||||
self.channels.subscribe(user_id, channel_id, role);
|
||||
}
|
||||
|
||||
pub fn unsubscribe_from_channel(&mut self, user_id: &UserId, channel_id: &ChannelId) {
|
||||
self.channels.unsubscribe(user_id, channel_id);
|
||||
}
|
||||
|
||||
pub fn is_user_online(&self, user_id: UserId) -> bool {
|
||||
!self
|
||||
.connected_users
|
||||
.get(&user_id)
|
||||
.unwrap_or(&Default::default())
|
||||
.connection_ids
|
||||
.is_empty()
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
pub fn check_invariants(&self) {
|
||||
for (connection_id, connection) in &self.connections {
|
||||
assert!(
|
||||
self.connected_users
|
||||
.get(&connection.user_id)
|
||||
.unwrap()
|
||||
.connection_ids
|
||||
.contains(connection_id)
|
||||
);
|
||||
}
|
||||
|
||||
for (user_id, state) in &self.connected_users {
|
||||
for connection_id in &state.connection_ids {
|
||||
assert_eq!(
|
||||
self.connections.get(connection_id).unwrap().user_id,
|
||||
*user_id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default, Serialize)]
|
||||
pub struct ChannelPool {
|
||||
by_user: HashMap<UserId, HashMap<ChannelId, ChannelRole>>,
|
||||
by_channel: HashMap<ChannelId, HashSet<UserId>>,
|
||||
}
|
||||
|
||||
impl ChannelPool {
|
||||
pub fn clear(&mut self) {
|
||||
self.by_user.clear();
|
||||
self.by_channel.clear();
|
||||
}
|
||||
|
||||
pub fn subscribe(&mut self, user_id: UserId, channel_id: ChannelId, role: ChannelRole) {
|
||||
self.by_user
|
||||
.entry(user_id)
|
||||
.or_default()
|
||||
.insert(channel_id, role);
|
||||
self.by_channel
|
||||
.entry(channel_id)
|
||||
.or_default()
|
||||
.insert(user_id);
|
||||
}
|
||||
|
||||
pub fn unsubscribe(&mut self, user_id: &UserId, channel_id: &ChannelId) {
|
||||
if let Some(channels) = self.by_user.get_mut(user_id) {
|
||||
channels.remove(channel_id);
|
||||
if channels.is_empty() {
|
||||
self.by_user.remove(user_id);
|
||||
}
|
||||
}
|
||||
if let Some(users) = self.by_channel.get_mut(channel_id) {
|
||||
users.remove(user_id);
|
||||
if users.is_empty() {
|
||||
self.by_channel.remove(channel_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_user(&mut self, user_id: &UserId) {
|
||||
if let Some(channels) = self.by_user.remove(user_id) {
|
||||
for channel_id in channels.keys() {
|
||||
self.unsubscribe(user_id, channel_id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn users_to_notify(
|
||||
&self,
|
||||
channel_id: ChannelId,
|
||||
) -> impl '_ + Iterator<Item = (UserId, ChannelRole)> {
|
||||
self.by_channel
|
||||
.get(&channel_id)
|
||||
.into_iter()
|
||||
.flat_map(move |users| {
|
||||
users.iter().flat_map(move |user_id| {
|
||||
Some((
|
||||
*user_id,
|
||||
self.by_user
|
||||
.get(user_id)
|
||||
.and_then(|channels| channels.get(&channel_id))
|
||||
.copied()?,
|
||||
))
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
136
crates/collab/src/seed.rs
Normal file
136
crates/collab/src/seed.rs
Normal file
@@ -0,0 +1,136 @@
|
||||
use crate::db::{self, ChannelRole, NewUserParams};
|
||||
|
||||
use anyhow::Context as _;
|
||||
use chrono::{DateTime, Utc};
|
||||
use db::Database;
|
||||
use serde::{Deserialize, de::DeserializeOwned};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
use crate::Config;
|
||||
|
||||
/// A GitHub user.
|
||||
///
|
||||
/// This representation corresponds to the entries in the `seed/github_users.json` file.
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GithubUser {
|
||||
id: i32,
|
||||
login: String,
|
||||
email: Option<String>,
|
||||
name: Option<String>,
|
||||
created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct SeedConfig {
|
||||
/// Which users to create as admins.
|
||||
admins: Vec<String>,
|
||||
/// Which channels to create (all admins are invited to all channels).
|
||||
channels: Vec<String>,
|
||||
}
|
||||
|
||||
pub async fn seed(config: &Config, db: &Database, force: bool) -> anyhow::Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
if !db.get_all_users(0, 1).await?.is_empty() && !force {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let seed_path = config
|
||||
.seed_path
|
||||
.as_ref()
|
||||
.context("called seed with no SEED_PATH")?;
|
||||
|
||||
let seed_config = load_admins(seed_path)
|
||||
.context(format!("failed to load {}", seed_path.to_string_lossy()))?;
|
||||
|
||||
let mut first_user = None;
|
||||
let mut others = vec![];
|
||||
|
||||
for admin_login in seed_config.admins {
|
||||
let user = fetch_github::<GithubUser>(
|
||||
&client,
|
||||
&format!("https://api.github.com/users/{admin_login}"),
|
||||
)
|
||||
.await;
|
||||
let user = db
|
||||
.create_user(
|
||||
&user.email.unwrap_or(format!("{admin_login}@example.com")),
|
||||
user.name.as_deref(),
|
||||
true,
|
||||
NewUserParams {
|
||||
github_login: user.login,
|
||||
github_user_id: user.id,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.context("failed to create admin user")?;
|
||||
if first_user.is_none() {
|
||||
first_user = Some(user.user_id);
|
||||
} else {
|
||||
others.push(user.user_id)
|
||||
}
|
||||
}
|
||||
|
||||
for channel in seed_config.channels {
|
||||
let (channel, _) = db
|
||||
.create_channel(&channel, None, first_user.unwrap())
|
||||
.await
|
||||
.context("failed to create channel")?;
|
||||
|
||||
for user_id in &others {
|
||||
db.invite_channel_member(
|
||||
channel.id,
|
||||
*user_id,
|
||||
first_user.unwrap(),
|
||||
ChannelRole::Admin,
|
||||
)
|
||||
.await
|
||||
.context("failed to add user to channel")?;
|
||||
}
|
||||
}
|
||||
|
||||
let github_users_filepath = seed_path.parent().unwrap().join("seed/github_users.json");
|
||||
let github_users: Vec<GithubUser> =
|
||||
serde_json::from_str(&fs::read_to_string(github_users_filepath)?)?;
|
||||
|
||||
for github_user in github_users {
|
||||
log::info!("Seeding {:?} from GitHub", github_user.login);
|
||||
|
||||
db.update_or_create_user_by_github_account(
|
||||
&github_user.login,
|
||||
github_user.id,
|
||||
github_user.email.as_deref(),
|
||||
github_user.name.as_deref(),
|
||||
github_user.created_at,
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("failed to insert user");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn load_admins(path: impl AsRef<Path>) -> anyhow::Result<SeedConfig> {
|
||||
let file_content = fs::read_to_string(path)?;
|
||||
Ok(serde_json::from_str(&file_content)?)
|
||||
}
|
||||
|
||||
async fn fetch_github<T: DeserializeOwned>(client: &reqwest::Client, url: &str) -> T {
|
||||
let mut request_builder = client.get(url);
|
||||
if let Ok(github_token) = std::env::var("GITHUB_TOKEN") {
|
||||
request_builder =
|
||||
request_builder.header("Authorization", format!("Bearer {}", github_token));
|
||||
}
|
||||
let response = request_builder
|
||||
.header("user-agent", "zed")
|
||||
.send()
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("failed to fetch '{url}': {error}"));
|
||||
let response_text = response.text().await.unwrap_or_else(|error| {
|
||||
panic!("failed to fetch '{url}': {error}");
|
||||
});
|
||||
serde_json::from_str(&response_text).unwrap_or_else(|error| {
|
||||
panic!("failed to deserialize github user from '{url}'. Error: '{error}', text: '{response_text}'");
|
||||
})
|
||||
}
|
||||
3
crates/collab/src/services.rs
Normal file
3
crates/collab/src/services.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod user_service;
|
||||
|
||||
pub use user_service::*;
|
||||
377
crates/collab/src/services/user_service.rs
Normal file
377
crates/collab/src/services/user_service.rs
Normal file
@@ -0,0 +1,377 @@
|
||||
use anyhow::{Context as _, anyhow};
|
||||
use async_trait::async_trait;
|
||||
use cloud_api_types::internal_api::{
|
||||
self, FuzzySearchChannelMembersByGithubLoginBody,
|
||||
FuzzySearchChannelMembersByGithubLoginResponse, FuzzySearchUsersBody, FuzzySearchUsersResponse,
|
||||
LookUpUserByGithubLoginBody, LookUpUserByGithubLoginResponse, LookUpUsersByLegacyIdBody,
|
||||
LookUpUsersByLegacyIdResponse,
|
||||
};
|
||||
use reqwest::RequestBuilder;
|
||||
use rpc::proto;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
use crate::Result;
|
||||
use crate::db::{Channel, UserId};
|
||||
use crate::entities::User;
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
pub use self::fake_user_service::*;
|
||||
|
||||
#[async_trait]
|
||||
pub trait UserService: Send + Sync + 'static {
|
||||
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>>;
|
||||
|
||||
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>>;
|
||||
|
||||
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>>;
|
||||
|
||||
// NOTE: This method is only tangentially related to users, but we're putting it on the `UserService` to avoid
|
||||
// introducing a separate service.
|
||||
//
|
||||
// We're also using the `proto::ChannelMember` representation in the return type, as we don't yet have a domain
|
||||
// representation of a channel member (and doesn't seem necessary to introduce one, at this point).
|
||||
async fn search_channel_members(
|
||||
&self,
|
||||
channel: &Channel,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)>;
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
fn as_fake(&self) -> std::sync::Arc<FakeUserService> {
|
||||
panic!("called as_fake on a real `UserService`");
|
||||
}
|
||||
}
|
||||
|
||||
/// A [`UserService`] implementation backed by Cloud.
|
||||
pub struct CloudUserService {
|
||||
http_client: reqwest::Client,
|
||||
zed_cloud_url: String,
|
||||
internal_api_key: String,
|
||||
}
|
||||
|
||||
impl CloudUserService {
|
||||
pub fn new(
|
||||
http_client: reqwest::Client,
|
||||
zed_cloud_url: String,
|
||||
internal_api_key: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
http_client,
|
||||
zed_cloud_url,
|
||||
internal_api_key,
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_request<T: DeserializeOwned + 'static>(
|
||||
&self,
|
||||
request: RequestBuilder,
|
||||
) -> Result<T> {
|
||||
let request = request
|
||||
.header("Content-Type", "application/json")
|
||||
.header(
|
||||
"Authorization",
|
||||
format!("Bearer {}", &self.internal_api_key),
|
||||
)
|
||||
.build()
|
||||
.context("failed to build request")?;
|
||||
|
||||
let response = self
|
||||
.http_client
|
||||
.execute(request)
|
||||
.await
|
||||
.context("failed to send request to Cloud")?;
|
||||
|
||||
let status = response.status();
|
||||
match response.error_for_status() {
|
||||
Ok(response) => {
|
||||
let response_body: T = response
|
||||
.json()
|
||||
.await
|
||||
.context("failed to parse response body")?;
|
||||
|
||||
Ok(response_body)
|
||||
}
|
||||
Err(_err) => Err(anyhow!("request to Cloud failed with status {status}",))?,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserService for CloudUserService {
|
||||
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
|
||||
let response_body: LookUpUsersByLegacyIdResponse = self
|
||||
.send_request(
|
||||
self.http_client
|
||||
.post(format!(
|
||||
"{}/internal/users/look_up_by_legacy_id",
|
||||
&self.zed_cloud_url
|
||||
))
|
||||
.json(&LookUpUsersByLegacyIdBody {
|
||||
legacy_user_ids: ids.into_iter().map(|id| id.0).collect(),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(response_body.users.into_iter().map(User::from).collect())
|
||||
}
|
||||
|
||||
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
|
||||
let response_body: LookUpUserByGithubLoginResponse = self
|
||||
.send_request(
|
||||
self.http_client
|
||||
.post(format!(
|
||||
"{}/internal/users/look_up_by_github_login",
|
||||
&self.zed_cloud_url
|
||||
))
|
||||
.json(&LookUpUserByGithubLoginBody {
|
||||
github_login: github_login.to_string(),
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(response_body.user.map(User::from))
|
||||
}
|
||||
|
||||
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
|
||||
let response_body: FuzzySearchUsersResponse = self
|
||||
.send_request(
|
||||
self.http_client
|
||||
.post(format!(
|
||||
"{}/internal/users/fuzzy_search",
|
||||
&self.zed_cloud_url
|
||||
))
|
||||
.json(&FuzzySearchUsersBody {
|
||||
query: query.to_string(),
|
||||
limit,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(response_body.users.into_iter().map(User::from).collect())
|
||||
}
|
||||
|
||||
async fn search_channel_members(
|
||||
&self,
|
||||
channel: &Channel,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
|
||||
let response_body: FuzzySearchChannelMembersByGithubLoginResponse = self
|
||||
.send_request(
|
||||
self.http_client
|
||||
.post(format!(
|
||||
"{}/internal/channel_members/fuzzy_search_by_github_login",
|
||||
&self.zed_cloud_url
|
||||
))
|
||||
.json(&FuzzySearchChannelMembersByGithubLoginBody {
|
||||
channel_id: channel.root_id().0,
|
||||
query: query.to_string(),
|
||||
limit,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let members = response_body
|
||||
.channel_members
|
||||
.into_iter()
|
||||
.map(channel_member_to_proto)
|
||||
.collect::<Vec<_>>();
|
||||
let users = response_body
|
||||
.users
|
||||
.into_iter()
|
||||
.map(User::from)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok((members, users))
|
||||
}
|
||||
}
|
||||
|
||||
fn channel_member_to_proto(member: internal_api::ChannelMember) -> proto::ChannelMember {
|
||||
let kind = match member.kind {
|
||||
internal_api::ChannelMemberKind::Member => proto::channel_member::Kind::Member,
|
||||
internal_api::ChannelMemberKind::Invitee => proto::channel_member::Kind::Invitee,
|
||||
};
|
||||
let role = match member.role {
|
||||
internal_api::ChannelMemberRole::Admin => proto::ChannelRole::Admin,
|
||||
internal_api::ChannelMemberRole::Member => proto::ChannelRole::Member,
|
||||
internal_api::ChannelMemberRole::Talker => proto::ChannelRole::Talker,
|
||||
internal_api::ChannelMemberRole::Guest => proto::ChannelRole::Guest,
|
||||
internal_api::ChannelMemberRole::Banned => proto::ChannelRole::Banned,
|
||||
};
|
||||
|
||||
proto::ChannelMember {
|
||||
user_id: UserId(member.legacy_user_id).to_proto(),
|
||||
kind: kind.into(),
|
||||
role: role.into(),
|
||||
}
|
||||
}
|
||||
|
||||
impl From<internal_api::User> for User {
|
||||
fn from(user: internal_api::User) -> Self {
|
||||
Self {
|
||||
id: UserId(user.legacy_user_id),
|
||||
avatar_url: user.avatar_url,
|
||||
github_login: user.github_login,
|
||||
name: user.name,
|
||||
admin: user.admin,
|
||||
connected_once: user.connected_once,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
mod fake_user_service {
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use collections::HashMap;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::db::Database;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct NewUserParams {
|
||||
pub github_login: String,
|
||||
pub github_user_id: i32,
|
||||
}
|
||||
|
||||
pub struct FakeUserService {
|
||||
this: Weak<Self>,
|
||||
state: Arc<Mutex<FakeUserServiceState>>,
|
||||
database: Arc<Database>,
|
||||
}
|
||||
|
||||
struct FakeUserServiceState {
|
||||
next_user_id: UserId,
|
||||
users: HashMap<UserId, User>,
|
||||
}
|
||||
|
||||
impl Default for FakeUserServiceState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
next_user_id: UserId(1),
|
||||
users: HashMap::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FakeUserService {
|
||||
pub fn new(database: Arc<Database>) -> Arc<Self> {
|
||||
Arc::new_cyclic(|this| Self {
|
||||
this: this.clone(),
|
||||
state: Arc::new(Mutex::default()),
|
||||
database,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_user(
|
||||
&self,
|
||||
email_address: &str,
|
||||
name: Option<&str>,
|
||||
admin: bool,
|
||||
params: NewUserParams,
|
||||
) -> UserId {
|
||||
let mut state = self.state.lock().await;
|
||||
|
||||
let user_id = state.next_user_id;
|
||||
let _ = email_address;
|
||||
state.users.insert(
|
||||
user_id,
|
||||
User {
|
||||
id: user_id,
|
||||
avatar_url: format!("https://github.com/{}.png?size=128", params.github_login),
|
||||
github_login: params.github_login,
|
||||
name: name.map(|name| name.to_string()),
|
||||
admin,
|
||||
connected_once: false,
|
||||
},
|
||||
);
|
||||
|
||||
state.next_user_id = UserId(state.next_user_id.0 + 1);
|
||||
|
||||
user_id
|
||||
}
|
||||
|
||||
pub async fn get_user_by_id(&self, id: UserId) -> Result<Option<User>> {
|
||||
let state = self.state.lock().await;
|
||||
|
||||
let user = state.users.get(&id).cloned();
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl UserService for FakeUserService {
|
||||
async fn get_users_by_ids(&self, ids: Vec<UserId>) -> Result<Vec<User>> {
|
||||
let state = self.state.lock().await;
|
||||
|
||||
let users = state
|
||||
.users
|
||||
.values()
|
||||
.filter(|user| ids.contains(&user.id))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
Ok(users)
|
||||
}
|
||||
|
||||
async fn get_user_by_github_login(&self, github_login: &str) -> Result<Option<User>> {
|
||||
let state = self.state.lock().await;
|
||||
|
||||
let user = state
|
||||
.users
|
||||
.values()
|
||||
.find(|user| user.github_login == github_login)
|
||||
.cloned();
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
|
||||
async fn fuzzy_search_users(&self, query: &str, limit: u32) -> Result<Vec<User>> {
|
||||
let _ = query;
|
||||
let _ = limit;
|
||||
unimplemented!("not currently exercised by any tests")
|
||||
}
|
||||
|
||||
async fn search_channel_members(
|
||||
&self,
|
||||
channel: &Channel,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
) -> Result<(Vec<proto::ChannelMember>, Vec<User>)> {
|
||||
let state = self.state.lock().await;
|
||||
|
||||
let users = state
|
||||
.users
|
||||
.values()
|
||||
.filter(|user| user.github_login.contains(query))
|
||||
.take(limit as usize)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let members = self
|
||||
.database
|
||||
.get_channel_memberships_for_user_ids(
|
||||
channel,
|
||||
users.iter().map(|user| user.id).collect(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((
|
||||
members
|
||||
.into_iter()
|
||||
.map(proto::ChannelMember::from)
|
||||
.collect(),
|
||||
users,
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
fn as_fake(&self) -> Arc<FakeUserService> {
|
||||
self.this.upgrade().unwrap()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user