first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
@@ -0,0 +1,135 @@
//! Supporting helpers for persisting cloud-object permissions to SQLite.
use anyhow::anyhow;
use cloud_objects::auth::UserUid;
use cloud_objects::cloud_object::{CloudLinkSharing, CloudObjectGuest, ServerObjectContainer};
use cloud_objects::drive::sharing::{SharingAccessLevel, Subject, TeamKind, UserKind};
use cloud_objects::ids::ServerId;
use serde::{Deserialize, Serialize};
/// 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"))
}
},
Subject::AnyoneWithLink(_) => {
// Link sharing is persisted separately in the schema.
Err(anyhow!("Anyone with the link not supported"))
}
}
}
}
#[cfg(test)]
#[path = "encoded_permissions_tests.rs"]
mod tests;
@@ -0,0 +1,74 @@
use cloud_objects::cloud_object::{CloudObjectGuest, ServerObjectContainer};
use cloud_objects::drive::sharing::{
LinkSharingSubjectType, SharingAccessLevel, Subject, TeamKind, UserKind,
};
use cloud_objects::ids::ServerId;
use lazy_static::lazy_static;
use session_sharing_protocol::common::{InputReplicaId, ProfileData};
use super::{decode_guests, encode_guests};
#[test]
fn test_roundtrip_guests() {
let guests = vec![
CloudObjectGuest {
subject: Subject::User(UserKind::Account(cloud_objects::UserUid::new(
"firebase_uid",
))),
access_level: SharingAccessLevel::Edit,
source: None,
},
CloudObjectGuest {
subject: Subject::PendingUser {
email: Some("pending@warp.dev".to_string()),
},
access_level: SharingAccessLevel::View,
source: Some(ServerObjectContainer::Folder {
folder_uid: ServerId::from_string_lossy("1234567890123456789012"),
}),
},
CloudObjectGuest {
subject: Subject::Team(TeamKind::Team {
team_uid: ServerId::from_string_lossy("abcdefghijklmnopqrstuv"),
}),
access_level: SharingAccessLevel::Edit,
source: None,
},
];
let encoded = encode_guests(&guests).expect("encode should succeed");
let decoded = decode_guests(&encoded).expect("decode should succeed");
assert_eq!(guests, decoded);
}
lazy_static! {
/// By construction, [`CloudObjectGuest`] only accepts `'static`-lifetime [`Subject`]s.
///
/// In most cases, this would prevent persisting a shared session subject, but we work around
/// it here for completeness;
static ref PROFILE_DATA: ProfileData = ProfileData {
firebase_uid: "2YP93GScglXJMdEr2Id12dI7HCG3".to_string(),
display_name: "Some User".to_string(),
photo_url: Some("http://example.com/some-image".to_string()),
email: Some("user@warp.dev".to_string()),
input_replica_id: InputReplicaId::from("some-id".to_string()),
};
}
#[test]
fn test_fail_unsupported_subjects() {
let result = encode_guests(&[CloudObjectGuest {
subject: Subject::AnyoneWithLink(LinkSharingSubjectType::Anyone),
access_level: SharingAccessLevel::View,
source: None,
}]);
assert!(result.is_err());
let result = encode_guests(&[CloudObjectGuest {
subject: Subject::User(UserKind::SharedSessionParticipant(PROFILE_DATA.clone())),
access_level: SharingAccessLevel::View,
source: None,
}]);
assert!(result.is_err());
}
@@ -0,0 +1,26 @@
//! This crate defines shared SQLite persistence infrastructure for Warp cloud objects.
//!
//! It owns model-agnostic persistence helpers for object metadata, permissions, refresh
//! scheduling, guest and link-sharing encoding, callback-based object upsert and delete
//! operations, and generic string object table access.
//!
//! It should not depend on `cloud_object_models`; model-specific read and write adapters
//! should live with the corresponding model modules.
mod encoded_permissions;
mod objects;
mod refresh;
pub use encoded_permissions::{
decode_guests, decode_link_sharing, encode_guests, encode_link_sharing,
};
pub use objects::{
CloudObjectId, CloudObjectReadContext, CreateCloudObjectFn, DeleteCloudObjectFn,
GenericStringObjectPersistenceData, GenericStringObjectRow, UpdateCloudObjectFn,
delete_cloud_object, delete_generic_string_object, id_from_metadata, increment_retry_count,
load_cloud_object_read_context, mark_object_as_synced, metadata_object_type_key,
read_generic_string_object_rows, to_cloud_object_metadata, to_cloud_object_permissions,
update_object_after_server_creation, update_object_metadata, upsert_cloud_object,
upsert_generic_string_objects,
};
pub use refresh::{read_time_of_next_force_object_refresh, record_time_of_next_refresh};
@@ -0,0 +1,660 @@
use std::collections::HashMap;
use cloud_objects::UserUid;
use cloud_objects::cloud_object::{
CloudObjectMetadata, CloudObjectPermissions, CloudObjectStatuses, CloudObjectSyncStatus,
GENERIC_STRING_OBJECT_PREFIX, GenericStringObjectFormat, NumInFlightRequests, ObjectIdType,
ObjectType, Owner, Revision, RevisionAndLastEditor, ServerCreationInfo,
};
use cloud_objects::ids::{ClientId, FolderId, HashableId, SyncId, ToServerId};
use diesel::result::Error;
use diesel::{Connection, ExpressionMethods, QueryDsl, RunQueryDsl, SqliteConnection};
use persistence::model::{
GenericStringObject as PersistedGenericStringObject, NewGenericStringObject, NewObjectMetadata,
NewObjectPermissions, ObjectMetadata, ObjectPermissions,
};
use persistence::schema;
use warp_core::features::FeatureFlag;
use warp_graphql::scalars::time::ServerTimestamp;
use crate::{decode_guests, decode_link_sharing, encode_guests, encode_link_sharing};
/// 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>>;
/// When delete a cloud object, this callback is used to delete the cloud
/// object. It takes the id of the cloud object to delete as a parameter.
/// The supplied conn has already started a transaction.
pub type DeleteCloudObjectFn =
Box<dyn FnOnce(&mut SqliteConnection, CloudObjectId) -> Result<(), Error>>;
/// Generic string object data prepared for persistence.
pub struct GenericStringObjectPersistenceData {
pub id: SyncId,
pub format: GenericStringObjectFormat,
pub metadata: CloudObjectMetadata,
pub permissions: CloudObjectPermissions,
pub data: String,
}
/// A generic string object row loaded from SQLite.
pub struct GenericStringObjectRow {
pub id: CloudObjectId,
pub data: String,
}
/// Cloud-object metadata and permissions loaded from SQLite for reconstructing typed objects.
pub struct CloudObjectReadContext {
metadata_by_id: HashMap<(CloudObjectId, String), ObjectMetadata>,
permissions_by_id: HashMap<CloudObjectId, ObjectPermissions>,
current_user_id: Option<UserUid>,
}
impl CloudObjectReadContext {
pub fn metadata_for_object(
&self,
shareable_object_id: CloudObjectId,
object_type: ObjectType,
) -> Option<&ObjectMetadata> {
self.metadata_by_id
.get(&(shareable_object_id, metadata_object_type_key(object_type)))
}
pub fn permissions_for_metadata(
&self,
metadata: &ObjectMetadata,
) -> Option<CloudObjectPermissions> {
let permissions = self.permissions_by_id.get(&metadata.id)?;
to_cloud_object_permissions(permissions, self.current_user_id)
}
}
pub fn load_cloud_object_read_context(
conn: &mut SqliteConnection,
current_user_id: Option<UserUid>,
) -> Result<CloudObjectReadContext, Error> {
let object_metadata =
schema::object_metadata::dsl::object_metadata.load::<ObjectMetadata>(conn)?;
let object_permissions =
schema::object_permissions::dsl::object_permissions.load::<ObjectPermissions>(conn)?;
// Cache metadata and permissions by id so that we aren't doing an n^2 lookups for each object type.
let metadata_by_id = object_metadata
.into_iter()
.map(|metadata| {
(
(metadata.shareable_object_id, metadata_key(&metadata)),
metadata,
)
})
.collect::<HashMap<_, _>>();
// Shareable object ids aren't unique across object types, so the object type needs to be
// part of the hashmap key. For generic objects, they are all in the same table,
// so it's safe to use the generic prefix as part of the key.
let permissions_by_id = object_permissions
.into_iter()
.map(|permissions| (permissions.object_metadata_id, permissions))
.collect::<HashMap<_, _>>();
Ok(CloudObjectReadContext {
metadata_by_id,
permissions_by_id,
current_user_id,
})
}
pub fn metadata_object_type_key(object_type: ObjectType) -> String {
match object_type {
ObjectType::GenericStringObject(_) => GENERIC_STRING_OBJECT_PREFIX.to_owned(),
ObjectType::Notebook | ObjectType::Workflow | ObjectType::Folder => {
object_type.sqlite_object_type_as_str().to_string()
}
}
}
fn metadata_key(metadata: &ObjectMetadata) -> String {
if metadata
.object_type
.starts_with(GENERIC_STRING_OBJECT_PREFIX)
{
GENERIC_STRING_OBJECT_PREFIX.to_owned()
} else {
metadata.object_type.to_owned()
}
}
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,
};
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();
let hashed_sync_id = sync_id.sqlite_uid_hash(cloud_object_type.into());
// 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 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)?;
}
if !cloud_object_metadata
.pending_changes_statuses
.has_pending_permissions_change
{
// Update the permissions.
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(())
}
/// Helper function to delete a cloud object identified by `sync_id`. If a valid object metadata row
/// for the object is found, `delete_object_fn` is called to delete the actual object.
pub fn delete_cloud_object(
conn: &mut SqliteConnection,
sync_id: SyncId,
object_id_type: ObjectIdType,
delete_object_fn: DeleteCloudObjectFn,
) -> Result<(), Error> {
use schema::object_metadata::dsl::*;
let hashed_sync_id = sync_id.sqlite_uid_hash(object_id_type);
// 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 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: ObjectMetadata = metadata_filter.first(conn)?;
let object_id = metadata.shareable_object_id;
diesel::delete(object_metadata.filter(id.eq(metadata.id))).execute(conn)?;
diesel::delete(
schema::object_permissions::dsl::object_permissions
.filter(schema::object_permissions::object_metadata_id.eq(metadata.id)),
)
.execute(conn)?;
diesel::delete(
schema::object_actions::dsl::object_actions
.filter(schema::object_actions::hashed_object_id.eq(hashed_sync_id)),
)
.execute(conn)?;
delete_object_fn(conn, object_id)?;
Ok(())
}
pub fn upsert_generic_string_objects(
conn: &mut SqliteConnection,
cloud_generic_string_objects: Vec<GenericStringObjectPersistenceData>,
) -> Result<(), Error> {
use schema::generic_string_objects::dsl::*;
conn.transaction::<(), Error, _>(|conn| {
for object in cloud_generic_string_objects {
let create_data = object.data.clone();
let update_data = object.data;
upsert_cloud_object(
conn,
ObjectType::GenericStringObject(object.format),
object.id,
object.metadata,
object.permissions,
Box::new(move |conn| {
let new_object = NewGenericStringObject { data: &create_data };
diesel::insert_into(
schema::generic_string_objects::dsl::generic_string_objects,
)
.values(new_object)
.execute(conn)?;
let object_id: i32 =
schema::generic_string_objects::dsl::generic_string_objects
.select(schema::generic_string_objects::columns::id)
.order(schema::generic_string_objects::columns::id.desc())
.first(conn)?;
Ok(object_id)
}),
Box::new(move |conn, object_id| {
diesel::update(
generic_string_objects
.filter(schema::generic_string_objects::dsl::id.eq(object_id)),
)
.set((data.eq(update_data),))
.execute(conn)?;
Ok(())
}),
)?
}
Ok(())
})
}
pub fn read_generic_string_object_rows(
conn: &mut SqliteConnection,
) -> Result<Vec<GenericStringObjectRow>, Error> {
Ok(schema::generic_string_objects::dsl::generic_string_objects
.load::<PersistedGenericStringObject>(conn)?
.into_iter()
.map(|object| GenericStringObjectRow {
id: object.id,
data: object.data,
})
.collect())
}
pub fn delete_generic_string_object(
conn: &mut SqliteConnection,
generic_string_object_id: CloudObjectId,
) -> Result<(), Error> {
diesel::delete(generic_string_objects.filter(id.eq(generic_string_object_id))).execute(conn)?;
Ok(())
}
/// Mark a shareable object as no longer having pending changes.
pub fn mark_object_as_synced(
conn: &mut SqliteConnection,
hashed_sqlite_id: String,
new_revision_and_editor: RevisionAndLastEditor,
new_metadata_ts: Option<ServerTimestamp>,
) -> Result<(), Error> {
conn.transaction::<(), Error, _>(|conn| {
diesel::update(object_metadata.filter(server_id.eq(Some(hashed_sqlite_id.as_str()))))
.set(is_pending.eq(false))
.execute(conn)?;
diesel::update(object_metadata.filter(server_id.eq(Some(hashed_sqlite_id.clone()))))
.set((
revision_ts.eq(new_revision_and_editor.revision.timestamp_micros()),
last_editor_uid.eq(new_revision_and_editor.last_editor_uid),
))
.execute(conn)?;
if let Some(metadata_ts) = new_metadata_ts {
diesel::update(object_metadata.filter(server_id.eq(Some(hashed_sqlite_id))))
.set((metadata_last_updated_ts.eq(metadata_ts.timestamp_micros()),))
.execute(conn)?;
}
Ok(())
})
}
pub fn increment_retry_count(
conn: &mut SqliteConnection,
server_id_string: String,
) -> Result<(), Error> {
conn.transaction::<(), Error, _>(|conn| {
diesel::update(object_metadata.filter(server_id.eq(Some(server_id_string))))
.set(retry_count.eq(retry_count + 1))
.execute(conn)?;
Ok(())
})
}
pub fn update_object_after_server_creation(
conn: &mut SqliteConnection,
client_id_string: String,
server_creation_info: ServerCreationInfo,
) -> Result<(), Error> {
use schema::commands::dsl::*;
conn.transaction::<(), Error, _>(|conn| {
diesel::update(object_metadata.filter(client_id.eq(Some(client_id_string.clone()))))
.set((
server_id.eq(Some(
server_creation_info
.server_id_and_type
.sqlite_type_and_uid_hash(),
)),
creator_uid.eq(server_creation_info.creator_uid),
))
.execute(conn)?;
diesel::update(commands.filter(cloud_workflow_id.eq(Some(client_id_string))))
.set(
cloud_workflow_id.eq(Some(
server_creation_info
.server_id_and_type
.sqlite_type_and_uid_hash(),
)),
)
.execute(conn)?;
Ok(())
})
}
/// SQLite endpoint for the ObjectMetadataUpdated RTC message that updates the metadata ts and other
/// metadata like current team_id of the object.
pub fn update_object_metadata(
conn: &mut SqliteConnection,
hashed_id: String,
metadata: CloudObjectMetadata,
) -> Result<(), Error> {
let metadata_last_updated_at = metadata
.metadata_last_updated_ts
.map(|ts| ts.timestamp_micros());
let trashed_timestamp = metadata.trashed_ts.map(|ts| ts.timestamp_micros());
let folder_id_str = metadata
.folder_id
.map(|folder_sync_id| folder_sync_id.sqlite_uid_hash(ObjectIdType::Folder));
conn.transaction::<(), Error, _>(|conn| {
diesel::update(object_metadata.filter(server_id.eq(Some(hashed_id.as_str()))))
.set((
metadata_last_updated_ts.eq(metadata_last_updated_at),
trashed_ts.eq(trashed_timestamp),
folder_id.eq(folder_id_str),
current_editor.eq(metadata.current_editor_uid),
))
.execute(conn)?;
Ok(())
})
}
pub fn id_from_metadata<K: HashableId + ToServerId>(metadata: &ObjectMetadata) -> Option<SyncId> {
match (&metadata.server_id, &metadata.client_id) {
(Some(server_id), _) => {
K::from_hash(server_id).map(|id| SyncId::ServerId(id.to_server_id()))
}
(None, Some(client_id)) => ClientId::from_hash(client_id).map(SyncId::ClientId),
_ => None,
}
}
pub fn to_cloud_object_metadata(metadata: &ObjectMetadata) -> CloudObjectMetadata {
CloudObjectMetadata {
current_editor_uid: metadata.current_editor.clone(),
metadata_last_updated_ts: metadata
.metadata_last_updated_ts
.and_then(|epoch| ServerTimestamp::from_unix_timestamp_micros(epoch).ok()),
revision: metadata
.revision_ts
.and_then(|epoch| Revision::from_unix_timestamp_micros(epoch).ok()),
pending_changes_statuses: CloudObjectStatuses {
pending_delete: false,
content_sync_status: if metadata.is_pending {
CloudObjectSyncStatus::InFlight(NumInFlightRequests(1))
} else {
CloudObjectSyncStatus::NoLocalChanges
},
has_pending_metadata_change: false,
has_pending_permissions_change: false,
pending_untrash: false,
},
trashed_ts: metadata
.trashed_ts
.and_then(|epoch| ServerTimestamp::from_unix_timestamp_micros(epoch).ok()),
folder_id: metadata.folder_id.as_ref().and_then(|folder_id_str| {
// First, attempt to convert the string into a server id.
let as_server_id =
FolderId::from_hash(folder_id_str).map(|id| SyncId::ServerId(id.into()));
if as_server_id.is_none() {
// If the string cannot be converted to server id, it may be a client id.
ClientId::from_hash(folder_id_str).map(SyncId::ClientId)
} else {
as_server_id
}
}),
is_welcome_object: metadata.is_welcome_object,
creator_uid: metadata.creator_uid.clone(),
last_editor_uid: metadata.last_editor_uid.clone(),
last_task_run_ts: None,
}
}
pub fn to_cloud_object_permissions(
permissions: &ObjectPermissions,
default_user_id: Option<UserUid>,
) -> Option<CloudObjectPermissions> {
let owner = owner_for_permissions(permissions, default_user_id)?;
let permissions_last_updated_ts = permissions
.permissions_last_updated_at
.and_then(|ts| ServerTimestamp::from_unix_timestamp_micros(ts).ok());
// If deserializing guests fails, default to None and wait for an eventual refresh.
let guests = if FeatureFlag::SharedWithMe.is_enabled() {
permissions
.object_guests
.as_deref()
.and_then(|guests| decode_guests(guests).ok())
.unwrap_or_default()
} else {
Default::default()
};
// If deserializing link sharing fails, default to None and wait for an
// eventual refresh.
let anyone_with_link = if FeatureFlag::SharedWithMe.is_enabled() {
permissions
.anyone_with_link_access_level
.as_deref()
.and_then(|access_level| {
decode_link_sharing(access_level, permissions.anyone_with_link_source.as_deref())
.ok()
})
} else {
None
};
Some(CloudObjectPermissions {
owner,
permissions_last_updated_ts,
guests,
anyone_with_link,
})
}
fn owner_for_permissions(
permissions: &ObjectPermissions,
default_user_id: Option<UserUid>,
) -> Option<Owner> {
match permissions.subject_type.as_str() {
"USER" => {
let user_uid = permissions
.subject_id
.as_deref()
.map(UserUid::new)
.or(default_user_id)?;
Some(Owner::User { user_uid })
}
"TEAM" => Some(Owner::Team {
team_uid: cloud_objects::ids::ServerId::from_string_lossy(&permissions.subject_uid),
}),
_ => None,
}
}
#[cfg(test)]
#[path = "objects_tests.rs"]
mod tests;
@@ -0,0 +1,71 @@
use cloud_objects::auth::UserUid;
use cloud_objects::cloud_object::{CloudObjectGuest, ServerObjectContainer};
use cloud_objects::drive::sharing::{
LinkSharingSubjectType, SharingAccessLevel, Subject, TeamKind, UserKind,
};
use cloud_objects::ids::ServerId;
use lazy_static::lazy_static;
use session_sharing_protocol::common::{InputReplicaId, ProfileData};
#[test]
fn test_roundtrip_guests() {
let guests = vec![
CloudObjectGuest {
subject: Subject::User(UserKind::Account(UserUid::new("firebase_uid"))),
access_level: SharingAccessLevel::Edit,
source: None,
},
CloudObjectGuest {
subject: Subject::PendingUser {
email: Some("pending@warp.dev".to_string()),
},
access_level: SharingAccessLevel::View,
source: Some(ServerObjectContainer::Folder {
folder_uid: 123.into(),
}),
},
CloudObjectGuest {
subject: Subject::Team(TeamKind::Team {
team_uid: ServerId::from(99),
}),
access_level: SharingAccessLevel::Edit,
source: None,
},
];
let encoded = super::encode_guests(&guests).expect("encode should succeed");
let decoded = super::decode_guests(&encoded).expect("decode should succeed");
assert_eq!(guests, decoded);
}
lazy_static! {
/// By construction, [`CloudObjectGuest`] only accepts `'static`-lifetime [`Subject`]s.
///
/// In most cases, this would prevent persisting a shared session subject, but we work around
/// it here for completeness;
static ref PROFILE_DATA: ProfileData = ProfileData {
firebase_uid: "2YP93GScglXJMdEr2Id12dI7HCG3".to_string(),
display_name: "Some User".to_string(),
photo_url: Some("http://example.com/some-image".to_string()),
email: Some("user@warp.dev".to_string()),
input_replica_id: InputReplicaId::from("some-id".to_string()),
};
}
#[test]
fn test_fail_unsupported_subjects() {
let result = super::encode_guests(&[CloudObjectGuest {
subject: Subject::AnyoneWithLink(LinkSharingSubjectType::Anyone),
access_level: SharingAccessLevel::View,
source: None,
}]);
assert!(result.is_err());
let result = super::encode_guests(&[CloudObjectGuest {
subject: Subject::User(UserKind::SharedSessionParticipant(PROFILE_DATA.clone())),
access_level: SharingAccessLevel::View,
source: None,
}]);
assert!(result.is_err());
}
@@ -0,0 +1,34 @@
use chrono::{DateTime, NaiveDateTime, Utc};
use diesel::result::Error;
use diesel::{Connection, QueryDsl, RunQueryDsl, SqliteConnection};
use persistence::model::NewCloudObjectsRefresh;
use persistence::schema;
pub fn record_time_of_next_refresh(
conn: &mut SqliteConnection,
timestamp: DateTime<Utc>,
) -> Result<(), Error> {
use schema::cloud_objects_refreshes::dsl::*;
let refresh = NewCloudObjectsRefresh {
time_of_next_refresh: timestamp.naive_utc(),
};
conn.transaction::<(), Error, _>(|conn| {
diesel::delete(cloud_objects_refreshes).execute(conn)?;
diesel::insert_into(cloud_objects_refreshes)
.values(refresh)
.execute(conn)?;
Ok(())
})
}
pub fn read_time_of_next_force_object_refresh(
conn: &mut SqliteConnection,
) -> Result<Option<DateTime<Utc>>, Error> {
// Find the smallest refresh timestamp to pass into CloudModel.
Ok(cloud_objects_refreshes
.select(time_of_next_refresh)
.load::<NaiveDateTime>(conn)?
.into_iter()
.map(|refresh| refresh.and_utc())
.min())
}