Rebrand to Galaxy, major improvements to Bedrock support, still needs some TLC though

This commit is contained in:
Ryan Ward
2026-05-07 11:29:34 -05:00
parent f4e2475c60
commit a41cbd8cc7
2433 changed files with 14208 additions and 9409 deletions
@@ -0,0 +1,3 @@
pub mod user_uid;
pub use user_uid::{TEST_USER_EMAIL, TEST_USER_UID, UserUid};
@@ -0,0 +1,87 @@
use std::{fmt, sync::LazyLock};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
pub const TEST_USER_EMAIL: &str = "test_user@warp.dev";
pub const TEST_USER_UID: &str = "test_user_uid";
/// UserUid represents the unique identifier for a user. Currently, this is a Firebase UID.
#[derive(Clone, Copy, PartialEq, Eq, Hash)]
pub struct UserUid(lasso::Spur);
static USER_UID_INTERNER: LazyLock<lasso::ThreadedRodeo<lasso::Spur>> =
LazyLock::new(lasso::ThreadedRodeo::new);
impl Default for UserUid {
fn default() -> Self {
// Intern an empty string so that `as_str()` on a default UserUid
// returns "" instead of panicking with "Key out of bounds".
Self::new("")
}
}
impl UserUid {
pub fn new(uid: &str) -> Self {
Self(USER_UID_INTERNER.get_or_intern(uid))
}
pub fn as_str(&self) -> &str {
USER_UID_INTERNER.resolve(&self.0)
}
pub fn as_string(&self) -> String {
self.as_str().to_string()
}
}
impl From<UserUid> for cynic::Id {
fn from(user_uid: UserUid) -> Self {
cynic::Id::new(user_uid.as_str())
}
}
impl fmt::Display for UserUid {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
impl fmt::Debug for UserUid {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("UserUid(")?;
f.write_str(self.as_str())?;
f.write_str(")")
}
}
impl Serialize for UserUid {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for UserUid {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct UidVisitor;
impl serde::de::Visitor<'_> for UidVisitor {
type Value = UserUid;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
formatter.write_str("a user UID")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
Ok(UserUid::new(v))
}
}
deserializer.deserialize_str(UidVisitor)
}
}
@@ -0,0 +1,998 @@
use std::{borrow::Cow, fmt, str::FromStr};
use anyhow::{Result, anyhow};
use chrono::{DateTime, Utc};
use derivative::Derivative;
use pathfinder_geometry::vector::vec2f;
use serde::{Deserialize, Serialize};
use galaxy_core::{
features::FeatureFlag,
ui::{Icon, appearance::Appearance, theme::Fill},
};
use galaxy_graphql::{object_permissions::AccessLevel, scalars::time::ServerTimestamp};
use galaxyui_core::{
Element,
elements::{
Align, ChildAnchor, ConstrainedBox, Hoverable, MouseStateHandle, OffsetPositioning,
ParentAnchor, ParentElement, ParentOffsetBounds, Stack,
},
ui_components::components::UiComponent,
};
use crate::{
auth::UserUid,
drive::sharing::{SharingAccessLevel, Subject, TeamKind, UserKind},
ids::{FolderId, ServerId, SyncId},
};
/// The type of object id each ObjectType corresponds to.
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum ObjectIdType {
Notebook,
Workflow,
Folder,
GenericStringObject,
}
impl ObjectIdType {
/// Returns the prefix for server IDs as we store them in sqlite. The prefix for these
/// objects is in title case unlike how we store the object types, which is why two different
/// APIs are needed.
pub fn sqlite_prefix(&self) -> &'static str {
match self {
ObjectIdType::Notebook => "Notebook",
ObjectIdType::Workflow => "Workflow",
ObjectIdType::Folder => "Folder",
ObjectIdType::GenericStringObject => "GenericStringObject",
}
}
}
/// A type for communicating the type of cloud object to/from the server, absent of the object itself.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize)]
pub enum ObjectType {
Notebook,
Workflow,
Folder,
GenericStringObject(GenericStringObjectFormat),
}
impl ObjectType {
/// Returns the serialized string for the object type, to be used for storing object_type in sqlite.
pub fn sqlite_object_type_as_str(&self) -> Cow<'_, str> {
match self {
ObjectType::Notebook => "NOTEBOOK".into(),
ObjectType::Workflow => "WORKFLOW".into(),
ObjectType::Folder => "FOLDER".into(),
ObjectType::GenericStringObject(format) => format.to_string().into(),
}
}
}
const NOTEBOOK_OBJECT_STRING: &str = "notebook";
const WORKFLOW_OBJECT_STRING: &str = "workflow";
const PROMPT_OBJECT_STRING: &str = "prompt";
const FOLDER_OBJECT_STRING: &str = "folder";
const ENV_VAR_COLLECTION_STRING: &str = "env-vars";
impl FromStr for ObjectType {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self> {
match s {
NOTEBOOK_OBJECT_STRING => Ok(Self::Notebook),
WORKFLOW_OBJECT_STRING => Ok(Self::Workflow),
PROMPT_OBJECT_STRING => Ok(Self::Workflow),
FOLDER_OBJECT_STRING => Ok(Self::Folder),
ENV_VAR_COLLECTION_STRING => Ok(Self::GenericStringObject(
GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection),
)),
_ => Err(anyhow!("Unexpected object type")),
}
}
}
impl fmt::Display for ObjectType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ObjectType::Notebook => write!(f, "{NOTEBOOK_OBJECT_STRING}"),
ObjectType::Workflow => write!(f, "{WORKFLOW_OBJECT_STRING}"),
ObjectType::Folder => write!(f, "{FOLDER_OBJECT_STRING}"),
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
JsonObjectType::EnvVarCollection,
)) => write!(f, "{ENV_VAR_COLLECTION_STRING}"),
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
JsonObjectType::AIFact,
)) => write!(f, "rule"),
ObjectType::GenericStringObject(_) => write!(f, "string_object_placeholder"), // placeholder value
}
}
}
impl From<ObjectType> for ObjectIdType {
fn from(value: ObjectType) -> Self {
match value {
ObjectType::Notebook => ObjectIdType::Notebook,
ObjectType::Workflow => ObjectIdType::Workflow,
ObjectType::Folder => ObjectIdType::Folder,
ObjectType::GenericStringObject(_) => ObjectIdType::GenericStringObject,
}
}
}
/// The object type prefix for generic string objects.
pub const GENERIC_STRING_OBJECT_PREFIX: &str = "GENERIC_STRING_";
/// The object type prefix for json objects.
pub const JSON_OBJECT_PREFIX: &str = "JSON_";
/// The data format for the generic string object type.
/// Right now we only support json, but this is left
/// open to support markdown, yaml and other text based types.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)]
pub enum GenericStringObjectFormat {
Json(JsonObjectType),
}
// Temporarily suppress clippy warnings about the `ToString` impl until we
// move `ObjectType` away from using `std::fmt::Display` for serialization.
#[allow(clippy::to_string_trait_impl)]
impl ToString for GenericStringObjectFormat {
fn to_string(&self) -> String {
match self {
GenericStringObjectFormat::Json(json_object_type) => format!(
"{}{}{}",
GENERIC_STRING_OBJECT_PREFIX,
JSON_OBJECT_PREFIX,
json_object_type.as_str()
),
}
}
}
/// An object sub-type for objects that implement the JsonModel trait.
#[derive(Copy, Clone, Debug, Eq, PartialEq, Serialize, Deserialize, Hash)]
pub enum JsonObjectType {
Preference,
EnvVarCollection,
WorkflowEnum,
AIFact,
MCPServer,
AIExecutionProfile,
TemplatableMCPServer,
CloudEnvironment,
ScheduledAmbientAgent,
CloudAgentConfig,
}
impl JsonObjectType {
pub fn as_str(&self) -> &'static str {
match self {
JsonObjectType::Preference => "PREFERENCE",
JsonObjectType::EnvVarCollection => "ENVVARCOLLECTION",
JsonObjectType::WorkflowEnum => "WORKFLOWENUM",
JsonObjectType::AIFact => "AIFACT",
JsonObjectType::MCPServer => "MCPSERVER",
JsonObjectType::AIExecutionProfile => "AIEXECUTIONPROFILE",
JsonObjectType::TemplatableMCPServer => "TEMPLATABLEMCPSERVER",
JsonObjectType::CloudEnvironment => "CLOUDENVIRONMENT",
JsonObjectType::ScheduledAmbientAgent => "SCHEDULEDAMBIENTAGENT",
JsonObjectType::CloudAgentConfig => "CLOUDAGENTCONFIG",
}
}
}
impl TryFrom<&str> for JsonObjectType {
type Error = anyhow::Error;
fn try_from(value: &str) -> std::result::Result<Self, Self::Error> {
match value {
"PREFERENCE" => Ok(JsonObjectType::Preference),
"ENVVARCOLLECTION" => Ok(JsonObjectType::EnvVarCollection),
"WORKFLOWENUM" => Ok(JsonObjectType::WorkflowEnum),
"AIFACT" => Ok(JsonObjectType::AIFact),
"MCPSERVER" => Ok(JsonObjectType::MCPServer),
"AIEXECUTIONPROFILE" => Ok(JsonObjectType::AIExecutionProfile),
"TEMPLATABLEMCPSERVER" => Ok(JsonObjectType::TemplatableMCPServer),
"CLOUDENVIRONMENT" => Ok(JsonObjectType::CloudEnvironment),
"SCHEDULEDAMBIENTAGENT" => Ok(JsonObjectType::ScheduledAmbientAgent),
"CLOUDAGENTCONFIG" => Ok(JsonObjectType::CloudAgentConfig),
_ => Err(anyhow!("could not convert unknown json object type")),
}
}
}
impl TryFrom<galaxy_graphql::object::ObjectType> for ObjectIdType {
type Error = anyhow::Error;
fn try_from(object_type: galaxy_graphql::object::ObjectType) -> Result<Self, Self::Error> {
match object_type {
galaxy_graphql::object::ObjectType::AIConversation => Err(anyhow!(
"AIConversation is not a supported object type for this operation"
)),
galaxy_graphql::object::ObjectType::Notebook => Ok(ObjectIdType::Notebook),
galaxy_graphql::object::ObjectType::Workflow => Ok(ObjectIdType::Workflow),
galaxy_graphql::object::ObjectType::Folder => Ok(ObjectIdType::Folder),
galaxy_graphql::object::ObjectType::GenericStringObject => {
Ok(ObjectIdType::GenericStringObject)
}
galaxy_graphql::object::ObjectType::Unknown => {
Err(anyhow!("could not convert unknown cloud object type"))
}
}
}
}
impl From<ObjectType> for galaxy_graphql::object::ObjectType {
fn from(value: ObjectType) -> Self {
match value {
ObjectType::Notebook => galaxy_graphql::object::ObjectType::Notebook,
ObjectType::Workflow => galaxy_graphql::object::ObjectType::Workflow,
ObjectType::Folder => galaxy_graphql::object::ObjectType::Folder,
ObjectType::GenericStringObject(GenericStringObjectFormat::Json(
JsonObjectType::EnvVarCollection,
)) => galaxy_graphql::object::ObjectType::GenericStringObject,
ObjectType::GenericStringObject(gso) => {
todo!("Moving is not implemented for {:?}", gso);
}
}
}
}
/// The revision timestamp at which an object was edited. This is used by the server
/// to determine if an edit to an object was at the latest revision. Edits at older
/// revisions are rejected by the server.
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, PartialOrd, Ord)]
pub struct Revision(ServerTimestamp);
impl Revision {
pub fn from_unix_timestamp_micros(ms_since_epoch: i64) -> Result<Self> {
let ts = ServerTimestamp::from_unix_timestamp_micros(ms_since_epoch)?;
Ok(Self(ts))
}
pub fn timestamp_micros(&self) -> i64 {
self.0.timestamp_micros()
}
pub fn utc(&self) -> DateTime<Utc> {
self.0.utc()
}
/// Returns the inner `ServerTimestamp`.
pub fn timestamp(&self) -> ServerTimestamp {
self.0
}
#[cfg(any(test, feature = "test-util"))]
pub fn now() -> Self {
Self(ServerTimestamp::new(Utc::now()))
}
}
impl From<Revision> for ServerTimestamp {
fn from(revision: Revision) -> Self {
revision.0
}
}
impl From<ServerTimestamp> for Revision {
fn from(time: ServerTimestamp) -> Self {
Revision(time)
}
}
#[cfg(any(test, feature = "test-util"))]
impl From<DateTime<Utc>> for Revision {
fn from(time: DateTime<Utc>) -> Self {
Self(ServerTimestamp::new(time))
}
}
/// The owner for a given object.
#[derive(Copy, Clone, Debug, Eq, Serialize, Deserialize, Derivative)]
#[derivative(PartialEq)]
pub enum Owner {
/// The owner of the object is a user (the object is in their personal drive).
User { user_uid: UserUid },
/// The owner of the object is a team (the object is in a team drive).
Team { team_uid: ServerId },
}
impl Owner {
/// A mock [`Owner`] ID for testing.
#[cfg(any(test, feature = "test-util"))]
pub fn mock_current_user() -> Owner {
use crate::auth::TEST_USER_UID;
Owner::User {
user_uid: UserUid::new(TEST_USER_UID),
}
}
}
impl From<Owner> for Option<ServerId> {
fn from(owner: Owner) -> Option<ServerId> {
match owner {
Owner::User { .. } => None,
Owner::Team { team_uid, .. } => Some(team_uid),
}
}
}
/// Server representation of an object's container. This corresponds to the `Container` GraphQL
/// type.
///
/// Containers are similar to, but not quite the same as, the [`CloudObjectLocation`] type.
/// Locations depend on object and user state - an object might currently be in the trash, or
/// it could be in one user's [shared space](Space::Shared) but another's
/// [team space](Space::Team). Containers, on the other hand, represent an object's canonical
/// parent - its one parent folder or drive that permissions are inherited from.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum ServerObjectContainer {
Folder { folder_uid: ServerId },
Drive { owner: Owner },
}
/// Server representation of a user object guest, as part of [`ServerObjectGuest`].
#[derive(Clone, Debug, PartialEq)]
pub enum ServerGuestSubject {
User { firebase_uid: String },
PendingUser { email: Option<String> },
Team { team_uid: ServerId },
}
/// Server representation of a link-sharing setting.
#[derive(Clone, Debug, PartialEq)]
pub struct ServerLinkSharing {
pub access_level: AccessLevel,
pub source: Option<ServerObjectContainer>,
}
/// Server representation of an object guest. This corresponds to the `ObjectGuest` GraphQL type.
#[derive(Clone, Debug, PartialEq)]
pub struct ServerObjectGuest {
pub subject: ServerGuestSubject,
pub access_level: AccessLevel,
/// If this guest is inherited, this is the ancestor that it's inherited from.
pub source: Option<ServerObjectContainer>,
}
/// Metadata for a cloud object that was fetched from the server.
#[derive(Clone, Debug)]
pub struct ServerMetadata {
pub uid: ServerId,
pub revision: Revision,
pub metadata_last_updated_ts: ServerTimestamp,
pub trashed_ts: Option<ServerTimestamp>,
pub folder_id: Option<FolderId>,
pub is_welcome_object: bool,
pub creator_uid: Option<String>,
pub last_editor_uid: Option<String>,
pub current_editor_uid: Option<String>,
}
/// Permissions for a cloud object that was fetched from the server.
#[derive(Clone, Debug, PartialEq)]
pub struct ServerPermissions {
/// The GraphQL definition of a `Space` is closer to the client's definition of an `Owner` (due
/// to sharing). This is also going to migrate back to [ServerMetadata] as part of the
/// `Container` migration.
pub space: Owner,
pub guests: Vec<ServerObjectGuest>,
pub anyone_link_sharing: Option<ServerLinkSharing>,
pub permissions_last_updated_ts: ServerTimestamp,
}
impl ServerPermissions {
#[cfg(any(test, feature = "test-util"))]
pub fn mock_personal() -> Self {
Self {
space: Owner::mock_current_user(),
guests: Vec::new(),
anyone_link_sharing: None,
permissions_last_updated_ts: DateTime::<Utc>::default().into(),
}
}
}
#[derive(Clone, Debug)]
pub struct NumInFlightRequests(pub usize);
#[derive(Clone, Debug)]
/// An enum representing what state a local cloud object's content changes can be in,
/// in relation to the server.
pub enum CloudObjectSyncStatus {
/// The object's content hasn't changed from what we believe the server's representation
/// to be.
NoLocalChanges,
/// The object's content has been modified locally, and is currently in the sync queue
/// attempting to sync up with the server.
InFlight(NumInFlightRequests),
/// The object's content has been modified locally but has unresolved conflict with the server
/// revision.
InConflict,
/// The object's content has been modified locally, but persisting the change on the server
/// could not complete for some reason.
Errored,
}
const SYNC_ICON_DIMENSIONS: f32 = 16.;
const SYNC_STATUS_TOOLTIP_LOCAL_ONLY: &str = "Saved locally";
const SYNC_STATUS_TOOLTIP_INFLIGHT: &str = "Saving";
const SYNC_STATUS_TOOLTIP_ERROR: &str = "Failed to save";
#[derive(Debug, Clone, PartialEq)]
pub struct CloudObjectPermissions {
pub owner: Owner,
pub permissions_last_updated_ts: Option<ServerTimestamp>,
pub anyone_with_link: Option<CloudLinkSharing>,
pub guests: Vec<CloudObjectGuest>,
}
impl CloudObjectPermissions {
pub fn new_from_server(server_permissions: ServerPermissions) -> Self {
let guests = if FeatureFlag::SharedWithMe.is_enabled() {
server_permissions
.guests
.into_iter()
.map(CloudObjectGuest::from_server)
.collect()
} else {
Vec::new()
};
let anyone_with_link = if FeatureFlag::SharedWithMe.is_enabled() {
server_permissions
.anyone_link_sharing
.map(CloudLinkSharing::from_server)
} else {
None
};
Self {
owner: server_permissions.space,
permissions_last_updated_ts: Some(server_permissions.permissions_last_updated_ts),
guests,
anyone_with_link,
}
}
/// Mock permissions for a personal object.
#[cfg(any(test, feature = "test-util"))]
pub fn mock_personal() -> Self {
Self {
owner: Owner::mock_current_user(),
permissions_last_updated_ts: Some(Utc::now().into()),
guests: Vec::new(),
anyone_with_link: None,
}
}
/// Returns `true` if the given user has direct personal access to this object —
/// either via an explicit user guest ACL entry or via link sharing.
/// Returns `false` if the only access is through a team guest ACL.
pub fn has_direct_user_access(&self, user_uid: UserUid) -> bool {
self.anyone_with_link.is_some() || self.guests.iter().any(|g| g.subject.is_user(user_uid))
}
/// Updates self from new permissions information received from the server
pub fn update_from_new_permissions_ts(&mut self, server_permissions: ServerPermissions) {
self.owner = server_permissions.space;
self.permissions_last_updated_ts = Some(server_permissions.permissions_last_updated_ts);
if FeatureFlag::SharedWithMe.is_enabled() {
self.guests = server_permissions
.guests
.into_iter()
.map(CloudObjectGuest::from_server)
.collect();
self.anyone_with_link = server_permissions
.anyone_link_sharing
.map(CloudLinkSharing::from_server);
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct CloudLinkSharing {
pub access_level: SharingAccessLevel,
// If this sharing setting was inherited, the `source` identifies the container it's inherited
// from.
pub source: Option<ServerObjectContainer>,
}
impl CloudLinkSharing {
pub fn from_server(server_link_sharing: ServerLinkSharing) -> Self {
Self {
access_level: server_link_sharing.access_level.into(),
source: server_link_sharing.source,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct CloudObjectGuest {
pub subject: Subject,
pub access_level: SharingAccessLevel,
/// If this guest was added to a container object, the `source` identifies that object.
pub source: Option<ServerObjectContainer>,
}
impl CloudObjectGuest {
pub fn from_server(server_guest: ServerObjectGuest) -> Self {
let subject = match server_guest.subject {
ServerGuestSubject::User { firebase_uid } => {
Subject::User(UserKind::Account(UserUid::new(&firebase_uid)))
}
ServerGuestSubject::PendingUser { email } => Subject::PendingUser { email },
ServerGuestSubject::Team { team_uid } => Subject::Team(TeamKind::Team { team_uid }),
};
Self {
subject,
access_level: server_guest.access_level.into(),
source: server_guest.source,
}
}
}
#[derive(Clone, Debug)]
pub struct CloudObjectMetadata {
pub revision: Option<Revision>,
pub metadata_last_updated_ts: Option<ServerTimestamp>,
pub current_editor_uid: Option<String>,
pub pending_changes_statuses: CloudObjectStatuses,
pub trashed_ts: Option<ServerTimestamp>,
pub folder_id: Option<SyncId>,
/// Welcome objects are created on the server when a user first recieves
/// access to Warp Drive as part of onboarding.
pub is_welcome_object: bool,
pub last_editor_uid: Option<String>,
pub creator_uid: Option<String>,
/// The "last used" timestamp for this environment.
///
/// This is populated via `GetCloudEnvironments` from
/// `CloudEnvironment.lastTaskCreated.createdAt`.
/// Only applicable for CloudEnvironment objects.
pub last_task_run_ts: Option<ServerTimestamp>,
}
impl CloudObjectMetadata {
pub fn new_from_server(server_metadata: ServerMetadata) -> Self {
Self {
revision: Some(server_metadata.revision),
current_editor_uid: server_metadata.current_editor_uid,
metadata_last_updated_ts: Some(server_metadata.metadata_last_updated_ts),
pending_changes_statuses: CloudObjectStatuses {
content_sync_status: CloudObjectSyncStatus::NoLocalChanges,
has_pending_metadata_change: false,
has_pending_permissions_change: false,
pending_untrash: false,
pending_delete: false,
},
trashed_ts: server_metadata.trashed_ts,
folder_id: server_metadata.folder_id.map(|id| id.into()),
is_welcome_object: server_metadata.is_welcome_object,
creator_uid: server_metadata.creator_uid,
last_editor_uid: server_metadata.last_editor_uid,
// last_task_run_ts is populated separately via GetCloudEnvironments query
last_task_run_ts: None,
}
}
/// Creates a new set of metadata with reasonable defaults for a test:
/// * Content and metadata timestamps set to now
/// * No editor information
/// * No parent folder
/// * Not trashed
#[cfg(any(test, feature = "test-util"))]
pub fn mock() -> Self {
Self {
revision: Some(Revision::now()),
current_editor_uid: None,
metadata_last_updated_ts: Some(Utc::now().into()),
pending_changes_statuses: CloudObjectStatuses::mock(),
trashed_ts: None,
folder_id: None,
is_welcome_object: false,
last_editor_uid: None,
creator_uid: None,
last_task_run_ts: None,
}
}
pub fn has_pending_content_changes(&self) -> bool {
!matches!(
self.pending_changes_statuses.content_sync_status,
CloudObjectSyncStatus::NoLocalChanges | CloudObjectSyncStatus::InConflict
)
}
pub fn is_errored(&self) -> bool {
matches!(
self.pending_changes_statuses.content_sync_status,
CloudObjectSyncStatus::Errored
)
}
/// True iff there are unsynced online-only changes for the object.
pub fn has_pending_online_only_change(&self) -> bool {
self.pending_changes_statuses.has_pending_permissions_change
|| self.pending_changes_statuses.has_pending_metadata_change
|| self.pending_changes_statuses.pending_untrash
|| self.pending_changes_statuses.pending_delete
}
pub fn set_current_editor(&mut self, editor_uid: Option<String>) {
self.current_editor_uid = editor_uid;
}
/// Updates revision and last_editor_uid from server metadata.
///
/// This unconditionally updates the revision and last_editor_uid, even if
/// there are conflicts, so callers should check for conflicts before calling
/// this.
pub fn update_revision_from_server(&mut self, server_metadata: &ServerMetadata) {
self.revision = Some(server_metadata.revision.clone());
self.last_editor_uid = server_metadata.last_editor_uid.clone();
}
/// Updates self from a new metadata received from the server
pub fn update_from_new_metadata_ts(&mut self, server_metadata: ServerMetadata) {
// Overwriting the metadata from an MetadataUpdated RTC message shouldn't overwrite
// the versioning of the object's data: the revision timestamp, has_pending_changes, conflict_status
// (if the object data is not being updated, the data versioning should stay the same.
self.current_editor_uid = server_metadata.current_editor_uid;
self.trashed_ts = server_metadata.trashed_ts;
self.folder_id = server_metadata.folder_id.map(|folder_id| folder_id.into());
self.creator_uid = server_metadata.creator_uid;
self.metadata_last_updated_ts = Some(server_metadata.metadata_last_updated_ts);
}
}
/// A struct holding the different statuses of pending changes that a cloud object might have.
/// Note that content is handled differently than permissions/metadata:
/// * Content changes go through the sync queue, and thus can exist in more states
/// * Metadata/permissions changes are synchronous operations, and thus are only either
/// in flight or synced
#[derive(Clone, Debug)]
pub struct CloudObjectStatuses {
pub content_sync_status: CloudObjectSyncStatus,
/// True iff there are unsynced permission changes for the object.
/// We intentionally don't persist this value in sqlite. And if true,
/// we don't upsert any in-memory permission changes to sqlite.
pub has_pending_permissions_change: bool,
/// True iff there are unsynced metadata changes for the object.
/// We intentionally don't persist this value in sqlite. And if true,
/// we don't upsert trashed and folder changes to sqlite.
pub has_pending_metadata_change: bool,
/// True iff there is an unsynced untrash operation on the object.
pub pending_untrash: bool,
/// True iff there is an unsynced delete operation on the object.
pub pending_delete: bool,
}
impl CloudObjectStatuses {
/// Empty statuses with no in-flight changes, for use in tests.
#[cfg(any(test, feature = "test-util"))]
pub fn mock() -> Self {
Self {
content_sync_status: CloudObjectSyncStatus::NoLocalChanges,
has_pending_permissions_change: false,
has_pending_metadata_change: false,
pending_untrash: false,
pending_delete: false,
}
}
pub fn render_icon(
&self,
sync_queue_is_dequeueing: bool,
hover_state: MouseStateHandle,
appearance: &Appearance,
) -> Option<Box<dyn Element>> {
let theme = appearance.theme();
let has_in_flight_requests = match &self.content_sync_status {
CloudObjectSyncStatus::InFlight(reqs) => reqs.0 > 0,
_ => false,
};
let should_show_local_only_indicator = has_in_flight_requests && !sync_queue_is_dequeueing;
let should_show_syncing_indicator = has_in_flight_requests
|| self.has_pending_metadata_change
|| self.has_pending_permissions_change
|| self.pending_untrash;
let should_show_error_indicator = matches!(
self.content_sync_status,
CloudObjectSyncStatus::Errored | CloudObjectSyncStatus::InConflict
);
let icon_and_tooltip_text = if should_show_local_only_indicator {
Some((
Icon::Laptop.to_galaxyui_icon(theme.main_text_color(theme.surface_1())),
SYNC_STATUS_TOOLTIP_LOCAL_ONLY,
))
} else if should_show_syncing_indicator {
Some((
Icon::Refresh.to_galaxyui_icon(theme.sub_text_color(theme.surface_2())),
SYNC_STATUS_TOOLTIP_INFLIGHT,
))
} else if should_show_error_indicator {
Some((
Icon::AlertTriangle.to_galaxyui_icon(Fill::Solid(theme.ui_error_color())),
SYNC_STATUS_TOOLTIP_ERROR,
))
} else {
None
};
if let Some((icon, tooltip_text)) = icon_and_tooltip_text {
return Some(
Align::new(
Hoverable::new(hover_state, move |hover_state| {
let mut stack = Stack::new().with_child(
ConstrainedBox::new(icon.finish())
.with_height(SYNC_ICON_DIMENSIONS)
.with_width(SYNC_ICON_DIMENSIONS)
.finish(),
);
if hover_state.is_hovered() {
let tooltip = appearance
.ui_builder()
.tool_tip(tooltip_text.to_string())
.build()
.finish();
stack.add_positioned_overlay_child(
tooltip,
OffsetPositioning::offset_from_parent(
vec2f(0., -24.),
ParentOffsetBounds::Unbounded,
ParentAnchor::Center,
ChildAnchor::Center,
),
);
}
stack.finish()
})
.finish(),
)
.finish(),
);
}
None
}
}
// Used for event tracking purposes, matches
// up with GraphQL enum of the same name.
#[derive(Copy, Default, Clone, Debug, Eq, PartialEq)]
pub enum CloudObjectEventEntrypoint {
TeamSettings,
ResourceCenter,
UniversalSearch,
ManagementUI,
Blocklist,
ImportModal,
Onboarding,
#[default]
Unknown,
}
// GraphQL conversion impls.
impl From<GenericStringObjectFormat>
for galaxy_graphql::generic_string_object::GenericStringObjectFormat
{
fn from(format: GenericStringObjectFormat) -> Self {
use galaxy_graphql::generic_string_object::GenericStringObjectFormat as GraphQLFormat;
match format {
GenericStringObjectFormat::Json(JsonObjectType::Preference) => {
GraphQLFormat::JsonPreference
}
GenericStringObjectFormat::Json(JsonObjectType::EnvVarCollection) => {
GraphQLFormat::JsonEnvVarCollection
}
GenericStringObjectFormat::Json(JsonObjectType::WorkflowEnum) => {
GraphQLFormat::JsonWorkflowEnum
}
GenericStringObjectFormat::Json(JsonObjectType::AIFact) => GraphQLFormat::JsonAIFact,
GenericStringObjectFormat::Json(JsonObjectType::MCPServer) => {
GraphQLFormat::JsonMCPServer
}
GenericStringObjectFormat::Json(JsonObjectType::AIExecutionProfile) => {
GraphQLFormat::JsonAIExecutionProfile
}
GenericStringObjectFormat::Json(JsonObjectType::TemplatableMCPServer) => {
GraphQLFormat::JsonTemplatableMCPServer
}
GenericStringObjectFormat::Json(JsonObjectType::CloudEnvironment) => {
GraphQLFormat::JsonCloudEnvironment
}
GenericStringObjectFormat::Json(JsonObjectType::ScheduledAmbientAgent) => {
GraphQLFormat::JsonScheduledAmbientAgent
}
GenericStringObjectFormat::Json(JsonObjectType::CloudAgentConfig) => {
unreachable!("JsonCloudAgentConfig is no longer present in GraphQL schema")
}
}
}
}
impl From<CloudObjectEventEntrypoint> for galaxy_graphql::object::CloudObjectEventEntrypoint {
fn from(entrypoint: CloudObjectEventEntrypoint) -> Self {
use galaxy_graphql::object::CloudObjectEventEntrypoint as GraphQLEntrypoint;
match entrypoint {
CloudObjectEventEntrypoint::TeamSettings => GraphQLEntrypoint::TeamSettings,
CloudObjectEventEntrypoint::ResourceCenter => GraphQLEntrypoint::ResourceCenter,
CloudObjectEventEntrypoint::UniversalSearch => GraphQLEntrypoint::UniversalSearch,
CloudObjectEventEntrypoint::ManagementUI => GraphQLEntrypoint::DriveIndex,
CloudObjectEventEntrypoint::Blocklist => GraphQLEntrypoint::Blocklist,
CloudObjectEventEntrypoint::ImportModal => GraphQLEntrypoint::ImportModal,
CloudObjectEventEntrypoint::Onboarding => GraphQLEntrypoint::Onboarding,
CloudObjectEventEntrypoint::Unknown => GraphQLEntrypoint::Unknown,
}
}
}
impl TryFrom<galaxy_graphql::object::ObjectMetadata> for ServerMetadata {
type Error = anyhow::Error;
fn try_from(value: galaxy_graphql::object::ObjectMetadata) -> Result<Self, Self::Error> {
let folder_id: Option<FolderId> = match value.parent {
galaxy_graphql::object::Container::FolderContainer(folder_container) => {
Some(folder_container.folder_uid.into_inner().into())
}
_ => None,
};
let metadata = ServerMetadata {
uid: ServerId::from_string_lossy(value.uid.inner()),
revision: value.revision_ts.into(),
metadata_last_updated_ts: value.metadata_last_updated_ts,
trashed_ts: value.trashed_ts,
folder_id,
is_welcome_object: value.is_welcome_object,
creator_uid: value.creator_uid.map(|uid| uid.into_inner()),
last_editor_uid: value.last_editor_uid.map(|uid| uid.into_inner()),
current_editor_uid: value.current_editor_uid.map(|uid| uid.into_inner()),
};
Ok(metadata)
}
}
impl TryFrom<galaxy_graphql::object_permissions::ObjectPermissions> for ServerPermissions {
type Error = anyhow::Error;
fn try_from(
value: galaxy_graphql::object_permissions::ObjectPermissions,
) -> Result<Self, Self::Error> {
let server_object_guests: Result<Vec<ServerObjectGuest>, _> = value
.guests
.into_iter()
.map(|guest| guest.try_into())
.collect();
let object_permissions = ServerPermissions {
space: value.space.try_into()?,
guests: server_object_guests?,
anyone_link_sharing: match value.anyone_link_sharing {
Some(sharing) => Some(sharing.try_into()?),
None => None,
},
permissions_last_updated_ts: value.last_updated_ts,
};
Ok(object_permissions)
}
}
impl TryFrom<galaxy_graphql::object_permissions::ObjectGuest> for ServerObjectGuest {
type Error = anyhow::Error;
fn try_from(value: galaxy_graphql::object_permissions::ObjectGuest) -> Result<Self, Self::Error> {
let object_guest = ServerObjectGuest {
subject: value.subject.try_into()?,
access_level: value.access_level,
source: match value.source {
Some(container) => Some(container.try_into()?),
None => None,
},
};
Ok(object_guest)
}
}
impl TryFrom<galaxy_graphql::object_permissions::GuestSubject> for ServerGuestSubject {
type Error = anyhow::Error;
fn try_from(
value: galaxy_graphql::object_permissions::GuestSubject,
) -> Result<Self, Self::Error> {
match value {
galaxy_graphql::object_permissions::GuestSubject::UserGuest(user_guest) => {
let guest_subject = ServerGuestSubject::User {
firebase_uid: user_guest.firebase_uid.into_inner(),
};
Ok(guest_subject)
}
galaxy_graphql::object_permissions::GuestSubject::PendingUserGuest(guest) => {
Ok(ServerGuestSubject::PendingUser { email: guest.email })
}
galaxy_graphql::object_permissions::GuestSubject::TeamGuest(team_guest) => {
Ok(ServerGuestSubject::Team {
team_uid: ServerId::from_string_lossy(team_guest.uid.inner()),
})
}
galaxy_graphql::object_permissions::GuestSubject::Unknown => {
anyhow::bail!("Unknown GuestSubject type")
}
}
}
}
impl TryFrom<galaxy_graphql::object_permissions::LinkSharing> for ServerLinkSharing {
type Error = anyhow::Error;
fn try_from(value: galaxy_graphql::object_permissions::LinkSharing) -> Result<Self, Self::Error> {
Ok(ServerLinkSharing {
access_level: value.access_level,
source: value.source.map(TryInto::try_into).transpose()?,
})
}
}
impl TryFrom<galaxy_graphql::object::Container> for ServerObjectContainer {
type Error = anyhow::Error;
fn try_from(value: galaxy_graphql::object::Container) -> Result<Self, Self::Error> {
match value {
galaxy_graphql::object::Container::FolderContainer(folder) => {
Ok(ServerObjectContainer::Folder {
folder_uid: ServerId::from_string_lossy(folder.folder_uid.inner()),
})
}
galaxy_graphql::object::Container::Space(space) => Ok(ServerObjectContainer::Drive {
owner: space.try_into()?,
}),
galaxy_graphql::object::Container::Unknown => {
anyhow::bail!("Unknown Container type")
}
}
}
}
impl TryFrom<galaxy_graphql::object::Space> for Owner {
type Error = anyhow::Error;
fn try_from(value: galaxy_graphql::object::Space) -> Result<Self, Self::Error> {
let owner = match value.type_ {
galaxy_graphql::object::SpaceType::Team => Owner::Team {
team_uid: ServerId::from_string_lossy(value.uid.inner()),
},
galaxy_graphql::object::SpaceType::User => Owner::User {
user_uid: UserUid::new(value.uid.inner()),
},
};
Ok(owner)
}
}
impl From<Owner> for galaxy_graphql::object_permissions::Owner {
fn from(owner: Owner) -> Self {
use galaxy_graphql::object_permissions::Owner as GraphQLOwner;
use galaxy_graphql::object_permissions::OwnerType;
match owner {
Owner::User { user_uid } => GraphQLOwner {
type_: OwnerType::User,
uid: Some(cynic::Id::new(user_uid.to_string())),
},
Owner::Team { team_uid, .. } => GraphQLOwner {
type_: OwnerType::Team,
uid: Some(cynic::Id::new(team_uid)),
},
}
}
}
@@ -0,0 +1 @@
pub mod sharing;
@@ -0,0 +1,221 @@
use std::str::FromStr;
use serde::{Deserialize, Serialize};
use session_sharing_protocol::common::{ProfileData as SessionSharingProfileData, Role};
use galaxy_graphql::object_permissions::AccessLevel;
use crate::{auth::UserUid, cloud_object::Owner, ids::ServerId};
#[derive(Copy, Clone, Debug, Eq, PartialEq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum SharingAccessLevel {
View,
Edit,
Full,
}
impl SharingAccessLevel {
pub fn label(&self) -> &'static str {
match self {
SharingAccessLevel::View => "Can view",
SharingAccessLevel::Edit => "Can edit",
SharingAccessLevel::Full => "Full access",
}
}
pub fn name(&self) -> &'static str {
match self {
SharingAccessLevel::View => "view",
SharingAccessLevel::Edit => "edit",
SharingAccessLevel::Full => "access",
}
}
/// Whether or not this access level implies the `Trash` action.
pub fn can_trash(self) -> bool {
self >= SharingAccessLevel::Edit
}
/// Whether or not this access level implies the `DeletePermanently` action.
pub fn can_delete(self) -> bool {
self >= SharingAccessLevel::Full
}
/// Whether or not this access level implies the `ChangeOwner` action.
pub fn can_move_drive(self) -> bool {
self >= SharingAccessLevel::Full
}
/// Whether or not this access level implies the `EditAccess` action.
pub fn can_edit_access(self) -> bool {
self >= SharingAccessLevel::Full
}
/// Convert this access level to a serializable value, which can be parsed by [`FromStr`].
pub fn to_serializable_value(self) -> &'static str {
match self {
SharingAccessLevel::View => "VIEW",
SharingAccessLevel::Edit => "EDIT",
SharingAccessLevel::Full => "FULL",
}
}
}
impl FromStr for SharingAccessLevel {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"VIEW" => Ok(Self::View),
"EDIT" => Ok(Self::Edit),
"FULL" => Ok(Self::Full),
_ => Err(anyhow::anyhow!("unknown access level {value}")),
}
}
}
impl From<AccessLevel> for SharingAccessLevel {
fn from(server_access: AccessLevel) -> Self {
match server_access {
AccessLevel::Viewer => Self::View,
AccessLevel::Editor => Self::Edit,
AccessLevel::Full => Self::Full,
}
}
}
impl From<SharingAccessLevel> for AccessLevel {
fn from(val: SharingAccessLevel) -> Self {
match val {
SharingAccessLevel::View => AccessLevel::Viewer,
SharingAccessLevel::Edit => AccessLevel::Editor,
SharingAccessLevel::Full => AccessLevel::Full,
}
}
}
impl From<Role> for SharingAccessLevel {
fn from(role: Role) -> Self {
match role {
Role::Reader => Self::View,
Role::Executor => Self::Edit,
Role::Full => Self::Full,
}
}
}
impl From<SharingAccessLevel> for Role {
fn from(access_level: SharingAccessLevel) -> Self {
match access_level {
SharingAccessLevel::View => Self::Reader,
SharingAccessLevel::Edit | SharingAccessLevel::Full => Self::Executor,
}
}
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum LinkSharingSubjectType {
None,
Anyone,
}
/// A `Subject` is someone with access to a shared object, like its owner or a directly-added
/// guest.
#[derive(Debug, Clone, PartialEq)]
pub enum Subject {
User(UserKind),
#[allow(dead_code)]
PendingUser {
email: Option<String>,
},
Team(TeamKind),
AnyoneWithLink(LinkSharingSubjectType),
}
/// A kind of user. In all cases, there is an underlying Warp account, but it's represented
/// differently in certain cases.
#[derive(Debug, Clone)]
pub enum UserKind {
/// A Warp user account, tracked in the [`UserProfiles`] model.
Account(UserUid),
/// A session-sharing participant.
// TODO(CLD-2283): Remove this once we have Firebase UIDs for shared session participants.
SharedSessionParticipant(SessionSharingProfileData),
}
/// A kind of team. Team permission updates are propagated differently for
/// shared sessions, so we need to store different info in certain cases.
#[derive(Debug, Clone, PartialEq)]
pub enum TeamKind {
Team {
team_uid: ServerId,
},
/// The team of the shared session sharer.
SharedSessionTeam {
team_uid: ServerId,
name: String,
},
}
impl TeamKind {
/// Gets the team UID.
pub fn team_uid(&self) -> ServerId {
match self {
TeamKind::Team { team_uid } => *team_uid,
TeamKind::SharedSessionTeam { team_uid, .. } => *team_uid,
}
}
}
impl Subject {
/// Convert an [`Owner`] into the closest [`Subject`] type.
pub fn from_owner(owner: Owner) -> Self {
match owner {
Owner::User { user_uid } => Subject::User(UserKind::Account(user_uid)),
Owner::Team { team_uid } => Subject::Team(TeamKind::Team { team_uid }),
}
}
/// Gets the user UID for this subject, if it has one.
pub fn user_uid(&self) -> Option<UserUid> {
match self {
Subject::User(user_kind) => match user_kind {
UserKind::Account(user_uid) => Some(*user_uid),
UserKind::SharedSessionParticipant(profile_data) => {
Some(UserUid::new(profile_data.firebase_uid.as_str()))
}
},
Subject::PendingUser { .. } => None,
Subject::Team(_) => None,
Subject::AnyoneWithLink(_) => None,
}
}
/// Checks if this subject refers to a given Firebase user directly.
pub fn is_user(&self, other_uid: UserUid) -> bool {
match self {
Subject::User(UserKind::Account(user_uid)) => *user_uid == other_uid,
Subject::User(UserKind::SharedSessionParticipant(profile_data)) => {
profile_data.firebase_uid.as_str() == other_uid.as_str()
}
_ => false,
}
}
/// Gets the team UID for this subject, if it has one.
pub fn team_uid(&self) -> Option<ServerId> {
match self {
Subject::Team(team_kind) => Some(team_kind.team_uid()),
_ => None,
}
}
}
impl PartialEq for UserKind {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::Account(self_uid), Self::Account(other_uid)) => self_uid == other_uid,
// Shared session participant data does not implement `PartialEq`. We only compare
// `UserKind`s in tests, so support isn't yet needed.
_ => false,
}
}
}
+415
View File
@@ -0,0 +1,415 @@
use std::fmt;
use itertools::Itertools;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use uuid::Uuid;
use crate::cloud_object::ObjectIdType;
/// Convert ID enums into and from a hashed UUID.
pub trait HashableId: Sized + Send + Sync {
fn to_hash(&self) -> String;
fn from_hash(hash: &str) -> Option<Self>;
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, schemars::JsonSchema)]
#[schemars(description = "A client-generated unique identifier.")]
pub struct ClientId(Uuid);
impl HashableId for ClientId {
fn to_hash(&self) -> String {
self.to_string()
}
fn from_hash(hash: &str) -> Option<ClientId> {
hash.strip_prefix("Client-")
.and_then(|s| Uuid::parse_str(s).ok())
.map(ClientId)
}
}
impl ClientId {
pub fn new() -> ClientId {
Self(Uuid::new_v4())
}
pub fn sqlite_hash(&self) -> String {
self.to_string()
}
}
impl Default for ClientId {
fn default() -> Self {
ClientId::new()
}
}
impl fmt::Display for ClientId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "Client-{}", self.0)
}
}
impl From<String> for ClientId {
fn from(s: String) -> Self {
ClientId::from_hash(&s).unwrap_or_default()
}
}
/// ID of an object in the sync queue.
#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq, schemars::JsonSchema)]
#[schemars(description = "Identifier for a synced object, either local or server-assigned.")]
pub enum SyncId {
/// Item has not been sync-ed yet. Using a client-created UUID.
#[schemars(
description = "A locally-generated identifier for an object not yet synced to the server."
)]
ClientId(ClientId),
/// Item has been sync-ed to the cloud. Using the server ID.
#[schemars(description = "A server-assigned identifier for a synced object.")]
ServerId(ServerId),
}
impl SyncId {
pub fn from_object_id<K>(id: K) -> Self
where
K: ToServerId,
{
Self::ServerId(id.to_server_id())
}
pub fn uid(&self) -> ObjectUid {
match self {
Self::ClientId(id) => id.to_string(),
Self::ServerId(id) => id.uid(),
}
}
pub fn sqlite_uid_hash(&self, object_id_type: ObjectIdType) -> String {
match self {
SyncId::ClientId(id) => id.sqlite_hash(),
SyncId::ServerId(id) => id.sqlite_type_and_uid_hash(object_id_type),
}
}
/// If this item has been synced to the cloud, extract its server ID.
pub fn into_server(self) -> Option<ServerId> {
match self {
Self::ServerId(id) => Some(id),
Self::ClientId(_) => None,
}
}
pub fn into_client(self) -> Option<ClientId> {
match self {
Self::ServerId(_) => None,
Self::ClientId(id) => Some(id),
}
}
}
impl settings_value::SettingsValue for SyncId {}
impl fmt::Display for SyncId {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Self::ServerId(id) => id.fmt(f),
Self::ClientId(id) => id.fmt(f),
}
}
}
impl From<ServerId> for SyncId {
fn from(id: ServerId) -> SyncId {
SyncId::ServerId(id)
}
}
/// Custom serialize function for SyncIds.
impl Serialize for SyncId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match self {
SyncId::ServerId(server_id) => server_id.serialize(serializer),
SyncId::ClientId(client_id) => client_id.to_hash().serialize(serializer),
}
}
}
/// Custom deserialize function for SyncIds.
impl<'de> Deserialize<'de> for SyncId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
// We try to deserialize as a ClientID, which only succeeds if the ID is prefixed with `Client-`.
// If that fails, we assume this is a server id and create a server ID.
if let Some(hashed) = ClientId::from_hash(s.as_str()) {
Ok(SyncId::ClientId(hashed))
} else {
Ok(SyncId::ServerId(ServerId::from_string_lossy(s)))
}
}
}
/// Length of the ServerId, should be in sync with the length picked for the server.
const SERVER_ID_LENGTH: usize = 22;
/// ServerId is a representation of a string-based unique ID we generate on the server,
/// of length SERVER_ID_LENGTH.
/// Because it's of fixed length, it can implement the Copy trait
/// (in contrast to simply using a String type).
#[derive(Clone, Copy, Default, Hash, PartialEq, Eq, schemars::JsonSchema)]
#[schemars(description = "A server-assigned unique identifier.")]
pub struct ServerId([char; SERVER_ID_LENGTH]);
/// For server IDs, this is the value that is stored
/// in the database. For client IDs, it is of the form "Client-{id}".
/// Used to index into cloud model and in most object read, write, and metadata
/// mutation server APIs.
pub type ObjectUid = String;
/// Corresponds to what is stored for a given object id within the local sqlite
/// database. Needed for backwards compatibility of the sqlite db following a refactor
/// that stripped the object type away from SyncID.
///
/// Of the format {sqlite_prefix}-{uid}.
///
/// Other than sqlite model events, this id is used for embedded objects within notebooks.
pub type HashedSqliteId = String;
/// UID for API keys.
pub type ApiKeyUid = String;
#[derive(Debug, thiserror::Error)]
pub enum ParseServerIdError {
#[error("ServerId must be exactly {SERVER_ID_LENGTH} characters, got {len}")]
InvalidLength { len: usize },
}
/// Removes the prefix from sqlite IDs to extract the UIDs. Should not be used unless there
/// is not other way to cleanly do the conversion, i.e., when we don't know the ID type.
#[allow(clippy::result_unit_err)]
pub fn parse_sqlite_id_to_uid(hashed_sqlite_id: HashedSqliteId) -> Result<ObjectUid, ()> {
let Some(uid) = hashed_sqlite_id.split("-").last() else {
return Err(());
};
Ok(uid.to_owned())
}
impl ServerId {
/// Convert a string input to a server ID. If the string is not exactly
/// [`SERVER_ID_LENGTH`] characters long, it will be truncated or padded as
/// necessary.
pub fn from_string_lossy(id: impl AsRef<str>) -> Self {
let id = id.as_ref();
Self::try_from(id).unwrap_or_else(|err| {
if cfg!(debug_assertions) {
panic!("{err}");
}
// ServerIds need to be exactly 22 characters, so to prevent a crash, we'll normalize
// the string. Nothing that uses it will work, but it's better than crashing.
let normalized = Self::normalize_id_str(id, 0);
Self::try_from(normalized).expect("id should convert")
})
}
/// Normalizes a string to be exactly 22 characters long.
fn normalize_id_str(input: &str, prefix_length: usize) -> String {
let available_len = SERVER_ID_LENGTH - prefix_length;
let truncated = if input.len() > available_len {
&input[input.len() - available_len..]
} else {
input
};
format!("{truncated:0>available_len$}")
}
pub fn uid(&self) -> ObjectUid {
(*self).into()
}
/// We need this API for backwards compatibility with local sqlite data.
/// In sqlite, objects are stored in object typy, uid pairs of the format
/// {sqlite-prefix}-{uid}. For example, for a workflow this would be
/// "Workflow-{uid}".
pub fn sqlite_type_and_uid_hash(&self, object_id_type: ObjectIdType) -> HashedSqliteId {
format!("{}-{}", object_id_type.sqlite_prefix(), self)
}
}
impl TryFrom<&str> for ServerId {
type Error = ParseServerIdError;
fn try_from(s: &str) -> Result<Self, Self::Error> {
match s.chars().collect_array() {
Some(chars) => Ok(Self(chars)),
None => Err(ParseServerIdError::InvalidLength {
len: s.chars().count(),
}),
}
}
}
impl TryFrom<String> for ServerId {
type Error = ParseServerIdError;
fn try_from(id: String) -> Result<Self, Self::Error> {
Self::try_from(id.as_str())
}
}
/// Creates a conversion between an i64 and a corresponding deterministic ServerId for use in tests.
/// An i64 like 123 will be converted to "test_uid00000000000123".
#[cfg(any(test, feature = "test-util"))]
impl From<i64> for ServerId {
fn from(id: i64) -> Self {
let prefix = "test_uid";
let id_str = id.abs().to_string();
let normalized = format!(
"{}{}",
prefix,
Self::normalize_id_str(&id_str, prefix.len())
);
Self::try_from(normalized).expect("normalized string should always be valid")
}
}
impl From<ServerId> for String {
fn from(id: ServerId) -> String {
String::from_iter(id.0)
}
}
/// We need our own implementation of serialize, due to ServerId being essentially a char array.
/// The default serializer in this case would spit a string that looks like an array, instead of a
/// nicely formatted string that we want. This implementation would serialize ServerId('a', 'b') to
/// "ab" instead.
impl Serialize for ServerId {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
let s: String = (*self).into();
serializer.serialize_str(&s)
}
}
impl<'de> Deserialize<'de> for ServerId {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let s: String = Deserialize::deserialize(deserializer)?;
ServerId::try_from(s.as_str()).map_err(serde::de::Error::custom)
}
}
impl std::fmt::Display for ServerId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
use std::fmt::Write;
for ch in self.0.iter() {
f.write_char(*ch)?;
}
Ok(())
}
}
impl std::fmt::Debug for ServerId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(f, "ServerId({self})")
}
}
pub trait ToServerId {
fn to_server_id(&self) -> ServerId;
}
#[derive(Clone, Debug, PartialEq)]
pub struct ServerIdAndType {
pub id: ServerId,
pub id_type: ObjectIdType,
}
impl ServerIdAndType {
pub fn sqlite_type_and_uid_hash(&self) -> HashedSqliteId {
self.id.sqlite_type_and_uid_hash(self.id_type)
}
}
/// string_id_traits is a macro used for generating implementations for the type aliases on
/// ServerId, implements different To/From and Display, and HashableId traits.
/// Takes type and desired prefix for HashableId.
#[macro_export]
macro_rules! server_id_traits {
($t:ty, $prefix:literal) => {
#[cfg(any(test, feature = "test-util"))]
impl From<i64> for $t {
fn from(id: i64) -> Self {
Self(id.into())
}
}
impl From<String> for $t {
fn from(id: String) -> Self {
Self($crate::ids::ServerId::from_string_lossy(id))
}
}
impl From<$t> for String {
fn from(id: $t) -> String {
id.0.into()
}
}
impl std::fmt::Display for $t {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
write!(f, "{}", self.0)
}
}
impl From<$t> for $crate::ids::ServerId {
fn from(id: $t) -> Self {
id.0
}
}
impl $crate::ids::HashableId for $t {
fn to_hash(&self) -> String {
format!("{}-{}", $prefix, self)
}
fn from_hash(hash: &str) -> Option<$t> {
hash.strip_prefix(&format!("{}-", $prefix))
.map(|s| s.to_string().into())
}
}
impl From<$crate::ids::ServerId> for $t {
fn from(id: $crate::ids::ServerId) -> Self {
Self(id)
}
}
impl $crate::ids::ToServerId for $t {
fn to_server_id(&self) -> $crate::ids::ServerId {
self.0
}
}
};
}
#[derive(Clone, Debug, Copy, PartialEq, Eq, Hash, Default)]
pub struct FolderId(ServerId);
server_id_traits! { FolderId, "Folder" }
impl From<FolderId> for SyncId {
fn from(id: FolderId) -> Self {
Self::ServerId(id.into())
}
}
+8
View File
@@ -0,0 +1,8 @@
pub mod auth;
pub mod cloud_object;
pub mod drive;
pub mod ids;
#[cfg(not(target_family = "wasm"))]
pub mod persistence;
pub use auth::UserUid;
@@ -0,0 +1,132 @@
//! Supporting types for persisting cloud objects to SQLite.
use anyhow::anyhow;
use serde::{Deserialize, Serialize};
use crate::{
auth::UserUid,
cloud_object::{CloudLinkSharing, CloudObjectGuest, ServerObjectContainer},
drive::sharing::{SharingAccessLevel, Subject, TeamKind, UserKind},
ids::ServerId,
};
/// Decode a link-sharing setting.
pub fn decode_link_sharing(
encoded_access_level: &str,
encoded_source: Option<&[u8]>,
) -> anyhow::Result<CloudLinkSharing> {
let access_level = encoded_access_level.parse()?;
let source = encoded_source.map(bincode::deserialize).transpose()?;
Ok(CloudLinkSharing {
access_level,
source,
})
}
/// Encode a link-sharing setting.
pub fn encode_link_sharing(
link_sharing: &CloudLinkSharing,
) -> anyhow::Result<(&'static str, Option<Vec<u8>>)> {
let source = link_sharing
.source
.as_ref()
.map(bincode::serialize)
.transpose()?;
Ok((link_sharing.access_level.to_serializable_value(), source))
}
/// Deserialize encoded object guests.
pub fn decode_guests(encoded_guests: &[u8]) -> anyhow::Result<Vec<CloudObjectGuest>> {
let persisted_guests = bincode::deserialize::<Vec<PersistedGuest>>(encoded_guests)?;
Ok(persisted_guests
.into_iter()
.map(PersistedGuest::into_cloud_object_guest)
.collect())
}
/// Encode object guests for persistence.
pub fn encode_guests(guests: &[CloudObjectGuest]) -> anyhow::Result<Vec<u8>> {
let persisted_guests = guests
.iter()
.map(PersistedGuest::try_from_cloud_object_guest)
.collect::<anyhow::Result<Vec<PersistedGuest>>>()?;
Ok(bincode::serialize(&persisted_guests)?)
}
/// Database representation of an object guest. These are [`bincode`]-serialized to support storing
/// an arbitrarily-long guest list.
#[derive(Serialize, Deserialize)]
struct PersistedGuest {
subject: PersistedSubject,
access_level: SharingAccessLevel,
source: Option<ServerObjectContainer>,
}
/// Database representation of a guest subject. This is restricted compared to the [`Subject`] type
/// since not all subjects are persisted.
#[derive(Serialize, Deserialize)]
enum PersistedSubject {
User { firebase_uid: String },
PendingUser { email: Option<String> },
Team { team_uid: ServerId },
}
impl PersistedGuest {
pub fn into_cloud_object_guest(self) -> CloudObjectGuest {
CloudObjectGuest {
subject: self.subject.into_subject(),
access_level: self.access_level,
source: self.source,
}
}
pub fn try_from_cloud_object_guest(guest: &CloudObjectGuest) -> anyhow::Result<Self> {
Ok(PersistedGuest {
subject: PersistedSubject::try_from_subject(&guest.subject)?,
access_level: guest.access_level,
source: guest.source,
})
}
}
impl PersistedSubject {
pub fn into_subject(self) -> Subject {
match self {
PersistedSubject::User { firebase_uid } => {
Subject::User(UserKind::Account(UserUid::new(&firebase_uid)))
}
PersistedSubject::PendingUser { email } => Subject::PendingUser { email },
PersistedSubject::Team { team_uid } => Subject::Team(TeamKind::Team { team_uid }),
}
}
/// Convert a [`Subject`] into a guest subject type. This is only supported for subjects that
/// may be direct object guests.
pub fn try_from_subject(subject: &Subject) -> anyhow::Result<Self> {
match subject {
Subject::User(user_kind) => match user_kind {
UserKind::Account(user_uid) => Ok(PersistedSubject::User {
firebase_uid: user_uid.to_string(),
}),
UserKind::SharedSessionParticipant(_) => {
// Shared sessions are transient, so we don't persist their ACLs to SQLite.
Err(anyhow!("Session-sharing participants not supported"))
}
},
Subject::PendingUser { email } => Ok(PersistedSubject::PendingUser {
email: email.clone(),
}),
Subject::Team(team_kind) => match team_kind {
TeamKind::Team { team_uid } => Ok(PersistedSubject::Team {
team_uid: *team_uid,
}),
TeamKind::SharedSessionTeam { .. } => {
// Shared sessions are transient, so we don't persist their ACLs to SQLite.
Err(anyhow!("Session-sharing teams not supported"))
}
},
// Link sharing is persisted separately in the schema.
Subject::AnyoneWithLink(_) => Err(anyhow!("Anyone with the link not supported")),
}
}
}
@@ -0,0 +1,247 @@
//! Persistence utilities for cloud objects.
mod cloud_objects;
use diesel::SqliteConnection;
use diesel::result::Error;
pub use cloud_objects::{decode_guests, decode_link_sharing, encode_guests, encode_link_sharing};
use crate::cloud_object::{
CloudObjectMetadata, CloudObjectPermissions, ObjectIdType, ObjectType, Owner,
};
use crate::ids::SyncId;
use persistence::model::{NewObjectMetadata, NewObjectPermissions, ObjectMetadata};
use persistence::schema;
use galaxy_core::features::FeatureFlag;
/// The sqlite id of a cloud object.
pub type CloudObjectId = i32;
/// When upserting a cloud object, this callback is used to create the cloud
/// object itself. It returns the id of the created cloud object.
/// Note: the supplied conn has already started a transaction.
pub type CreateCloudObjectFn =
Box<dyn FnOnce(&mut SqliteConnection) -> Result<CloudObjectId, Error>>;
/// When upserting a cloud object, this callback is used to update the cloud
/// object. It takes the id of the cloud object to update as a parameter.
/// The supplied conn has already started a transaction.
pub type UpdateCloudObjectFn =
Box<dyn FnOnce(&mut SqliteConnection, CloudObjectId) -> Result<(), Error>>;
pub fn upsert_cloud_object(
conn: &mut SqliteConnection,
cloud_object_type: ObjectType,
sync_id: SyncId,
cloud_object_metadata: CloudObjectMetadata,
cloud_object_permissions: CloudObjectPermissions,
create_object_fn: CreateCloudObjectFn,
update_object_fn: UpdateCloudObjectFn,
) -> Result<(), Error> {
use schema::object_metadata::dsl::{
client_id, current_editor, folder_id, is_pending, last_editor_uid,
metadata_last_updated_ts, object_metadata, revision_ts, server_id, trashed_ts,
};
use schema::object_permissions::dsl::{
anyone_with_link_access_level, anyone_with_link_source, object_guests, object_metadata_id,
object_permissions, permissions_last_updated_at, subject_id, subject_type, subject_uid,
};
use diesel::prelude::*;
let (subject_type_value, subject_id_value, subject_uid_value) =
match cloud_object_permissions.owner {
Owner::User { user_uid } => ("USER", Some(user_uid.to_string()), user_uid.to_string()),
Owner::Team { team_uid } => ("TEAM", None, team_uid.to_string()),
};
let permissions_ts = cloud_object_permissions
.permissions_last_updated_ts
.map(|ts| ts.timestamp_micros());
let guests = if FeatureFlag::SharedWithMe.is_enabled() {
match encode_guests(&cloud_object_permissions.guests) {
Ok(guests) => Some(guests),
Err(err) => {
log::warn!("Unable to encode guests: {err:#}");
None
}
}
} else {
None
};
let (anyone_with_link_access_level_value, anyone_with_link_source_value) =
if FeatureFlag::SharedWithMe.is_enabled() {
match cloud_object_permissions
.anyone_with_link
.as_ref()
.map(encode_link_sharing)
{
Some(Ok((access_level, source))) => (Some(access_level), source),
Some(Err(err)) => {
log::warn!("Unable to encode link-sharing setting: {err:#}");
(None, None)
}
None => (None, None),
}
} else {
(None, None)
};
let revision = cloud_object_metadata
.revision
.as_ref()
.map(|r| r.timestamp_micros());
let has_pending_content_changes = cloud_object_metadata.has_pending_content_changes();
// Filter to find metadata row.
// The diesel types for `filter`s are dependent on the columns being filtered
// so while the `hashed_sync_id` will only match one of `client_id` and `server_id`,
// we filter on both here for ergonomics.
let hashed_sync_id = sync_id.sqlite_uid_hash(cloud_object_type.into());
let metadata_filter = object_metadata
.filter(client_id.eq(Some(hashed_sync_id.as_str())))
.or_filter(server_id.eq(Some(hashed_sync_id.as_str())));
let metadata: Option<ObjectMetadata> = metadata_filter.first(conn).ok();
match metadata {
Some(metadata) => {
// The object already exists in sqlite so update the object.
update_object_fn(conn, metadata.shareable_object_id)?;
let metadata_last_updated_at = cloud_object_metadata
.metadata_last_updated_ts
.map(|ts| ts.timestamp_micros());
let trashed_timestamp = cloud_object_metadata
.trashed_ts
.map(|ts| ts.timestamp_micros());
let folder_id_str = cloud_object_metadata
.folder_id
.map(|folder_sync_id| folder_sync_id.sqlite_uid_hash(ObjectIdType::Folder));
// Update the metadata. Note: this is holistic write of all the metadata based on the current state of the in-memory object.
// TODO: we need to update author_id as well.
diesel::update(metadata_filter)
.set((
revision_ts.eq(revision),
is_pending.eq(has_pending_content_changes),
last_editor_uid.eq(cloud_object_metadata.last_editor_uid),
))
.execute(conn)?;
if !cloud_object_metadata
.pending_changes_statuses
.has_pending_metadata_change
{
diesel::update(metadata_filter)
.set((
metadata_last_updated_ts.eq(metadata_last_updated_at),
trashed_ts.eq(trashed_timestamp),
folder_id.eq(folder_id_str),
current_editor.eq(cloud_object_metadata.current_editor_uid),
))
.execute(conn)?;
}
// Update the permissions.
if !cloud_object_metadata
.pending_changes_statuses
.has_pending_permissions_change
{
let permissions_filter =
object_permissions.filter(object_metadata_id.eq(metadata.id));
diesel::update(permissions_filter)
.set((
subject_type.eq(subject_type_value),
subject_id.eq(subject_id_value),
subject_uid.eq(subject_uid_value),
permissions_last_updated_at.eq(permissions_ts),
object_guests.eq(guests),
anyone_with_link_access_level.eq(anyone_with_link_access_level_value),
anyone_with_link_source.eq(anyone_with_link_source_value),
))
.execute(conn)?;
}
}
None => {
// The object doesn't exist in sqlite so create the object.
let object_id = create_object_fn(conn)?;
// Create the metadata.
let mut new_object_metadata = NewObjectMetadata {
object_type: cloud_object_type.sqlite_object_type_as_str().to_string(),
revision_ts: revision,
shareable_object_id: object_id,
is_pending: has_pending_content_changes,
retry_count: 0,
// TODO: we need to deserialize this from graphql.
author_id: None,
// One of these is set below.
client_id: None,
server_id: None,
metadata_last_updated_ts: cloud_object_metadata
.metadata_last_updated_ts
.map(|ts| ts.timestamp_micros()),
trashed_ts: cloud_object_metadata
.trashed_ts
.map(|ts| ts.timestamp_micros()),
folder_id: cloud_object_metadata
.folder_id
.map(|sync_id| sync_id.sqlite_uid_hash(ObjectIdType::Folder)),
// When we insert an object, mark whether it's a welcome object. This
// field won't ever be updated and this is the only pathway for it to be set.
is_welcome_object: cloud_object_metadata.is_welcome_object,
creator_uid: cloud_object_metadata.creator_uid,
last_editor_uid: cloud_object_metadata.last_editor_uid,
current_editor: cloud_object_metadata.current_editor_uid,
};
// There are two distinct cases:
// - If the client created this object, the clientId will be set. There is another model event to set the server id.
// - Otherwise, the server notified the client about this object so only the serverId will be set.
match sync_id {
SyncId::ClientId(_) => {
new_object_metadata.client_id = Some(hashed_sync_id);
}
SyncId::ServerId(_) => {
new_object_metadata.server_id = Some(hashed_sync_id);
}
}
diesel::insert_into(schema::object_metadata::dsl::object_metadata)
.values(new_object_metadata)
.execute(conn)?;
// Retrieve the ID of the row that was just inserted. We need to
// do it this way because sqlite doesn't support RETURNING.
let metadata_id: i32 = schema::object_metadata::dsl::object_metadata
.select(schema::object_metadata::dsl::id)
.order(schema::object_metadata::dsl::id.desc())
.first(conn)?;
// Create the permissions.
let new_object_permissions = NewObjectPermissions {
object_metadata_id: metadata_id,
subject_type: subject_type_value.to_owned(),
subject_id: subject_id_value,
subject_uid: subject_uid_value,
permissions_last_updated_at: permissions_ts,
object_guests: guests,
anyone_with_link_access_level: anyone_with_link_access_level_value,
anyone_with_link_source: anyone_with_link_source_value,
};
diesel::insert_into(schema::object_permissions::dsl::object_permissions)
.values(new_object_permissions)
.execute(conn)?;
}
}
Ok(())
}