Skip to main content

spiral/
project.rs

1//! Project handles, immutable Catalog commit snapshots and pinned branch heads.
2use std::collections::HashSet;
3use std::ops::Deref;
4
5#[cfg(feature = "tables")]
6use crate::Table;
7use crate::api::authorization::ListGrantsRequest;
8use crate::catalog_types::{
9    CommitSelector, CommitTarget, CreateBranchRequest, IdempotencyKey, NamespacePath,
10    NamespaceSegment, PageSize, ProjectBranchId, ProjectBranchName, ProjectCommitId, ProjectId,
11    ProjectResourceState, ReadJournalStateRequest, ResolveJournalPathRequest, ResourceAtSnapshot,
12};
13use crate::commit::{Commit, FrozenCommit, SavedCommit};
14use crate::resources::project::{
15    Grant, GrantRoleRequest, GrantRoleResponse, Presentation, PresentationKey, Principal,
16    Project as ProjectInfo, SavePresentation,
17};
18#[cfg(feature = "tables")]
19use crate::table::CatalogBase;
20use crate::{Client, Error, List};
21
22/// Entry point for project resources, bound to one authenticated connection.
23#[derive(Clone)]
24pub struct Project {
25    pub(crate) client: Client,
26    pub(crate) id: ProjectId,
27}
28impl Client {
29    /// Select a project without making a network request.
30    pub fn project(&self, id: impl AsRef<str>) -> Result<Project, Error> {
31        Ok(Project {
32            client: self.clone(),
33            id: ProjectId::new(id.as_ref()).map_err(Error::invalid)?,
34        })
35    }
36}
37impl Project {
38    /// The client whose credentials and execution backend this Project uses.
39    pub fn client(&self) -> &Client {
40        &self.client
41    }
42
43    /// Stable project identifier.
44    pub fn id(&self) -> &ProjectId {
45        &self.id
46    }
47    /// Pin the current head of a branch, which a [`Commit`] can publish onto.
48    pub async fn branch(&self, name: &str) -> Result<BranchHead, Error> {
49        let branch = ProjectBranchName::new(name).map_err(Error::invalid)?;
50        let pinned = self
51            .client
52            .connection
53            .catalog
54            .pin_journal(&self.id, &branch)
55            .await?;
56        pinned.validate().map_err(Error::invalid)?;
57        Ok(BranchHead::new(
58            self.clone(),
59            pinned.head_commit_id,
60            pinned.branch_id,
61        ))
62    }
63    /// Create a branch at an existing project commit and pin its head.
64    pub async fn create_branch(
65        &self,
66        name: &str,
67        commit_id: ProjectCommitId,
68    ) -> Result<BranchHead, Error> {
69        ProjectCommitId::new(commit_id.0).map_err(Error::invalid)?;
70        let branch = self
71            .client
72            .connection
73            .catalog
74            .create_journal_branch(
75                &self.id,
76                &CreateBranchRequest {
77                    name: ProjectBranchName::new(name).map_err(Error::invalid)?,
78                    target: CommitSelector::Commit(CommitTarget { commit_id }),
79                    idempotency_key: IdempotencyKey::new(format!("branch-{name}-{commit_id}"))
80                        .map_err(Error::invalid)?,
81                },
82            )
83            .await?;
84        branch.validate().map_err(Error::invalid)?;
85        Ok(BranchHead::new(
86            self.clone(),
87            branch.head_commit_id,
88            branch.branch_id,
89        ))
90    }
91
92    /// Open an exact commit. Its snapshot is read-only: commits publish onto a [`BranchHead`].
93    pub fn snapshot_at(&self, commit_id: ProjectCommitId) -> Snapshot {
94        Snapshot {
95            project: self.clone(),
96            commit_id,
97            branch_id: None,
98        }
99    }
100    /// Resume a commit saved with [`FrozenCommit::save`], against its original base and branch.
101    pub fn resume_commit(&self, saved: SavedCommit) -> Result<FrozenCommit, Error> {
102        FrozenCommit::resume(self, saved)
103    }
104    /// Pin the branch head and resolve a table to an immutable version.
105    #[cfg(feature = "tables")]
106    pub async fn table(&self, branch: &str, path: &str) -> Result<Table, Error> {
107        self.table_session(branch).await?.table(path).await
108    }
109}
110
111/// Immutable project state selected by one exact Catalog commit.
112///
113/// A snapshot pinned from a branch also records the branch; [`BranchHead`] is that pin.
114#[derive(Clone)]
115pub struct Snapshot {
116    pub(crate) project: Project,
117    pub(crate) commit_id: ProjectCommitId,
118    pub(crate) branch_id: Option<ProjectBranchId>,
119}
120impl Snapshot {
121    /// Exact commit selected by this snapshot.
122    pub fn commit_id(&self) -> ProjectCommitId {
123        self.commit_id
124    }
125    /// Stable branch selected when pinning, or None for an exact-commit snapshot.
126    pub fn branch_id(&self) -> Option<ProjectBranchId> {
127        self.branch_id
128    }
129    /// Project owning this state.
130    pub fn project(&self) -> &Project {
131        &self.project
132    }
133    /// Read resources at this commit, following bounded server pages.
134    pub async fn resources(&self) -> Result<Vec<ProjectResourceState>, Error> {
135        let mut resources = Vec::new();
136        let mut page_token = None;
137        let mut seen = HashSet::new();
138        loop {
139            let page = self
140                .project
141                .client
142                .connection
143                .catalog
144                .read_journal_state(
145                    &self.project.id,
146                    self.commit_id,
147                    &ReadJournalStateRequest {
148                        page_token: page_token.clone(),
149                        page_size: Some(PageSize::default()),
150                    },
151                )
152                .await?;
153            if page.commit_id != self.commit_id {
154                return Err(Error::invalid("Catalog changed the selected commit"));
155            }
156            for resource in &page.resources {
157                resource.validate().map_err(Error::invalid)?;
158            }
159            resources.extend(page.resources);
160            let Some(next) = page.next_page_token else {
161                return Ok(resources);
162            };
163            next.validate().map_err(Error::invalid)?;
164            if !seen.insert(next.clone()) {
165                return Err(Error::invalid("Catalog repeated a page token"));
166            }
167            page_token = Some(next);
168        }
169    }
170    /// Resolve a resource address with this commit as its default snapshot.
171    /// Use [`Self::table_session`] to share explicit branch pins across lookups.
172    #[cfg(feature = "tables")]
173    pub async fn table(&self, path: &str) -> Result<Table, Error> {
174        self.table_session().table(path).await
175    }
176
177    /// Resolve the resource that `path` names at this commit.
178    pub(crate) async fn resource_at_path(
179        &self,
180        path: &NamespacePath,
181    ) -> Result<ResourceAtSnapshot, Error> {
182        let resolved = self
183            .project
184            .client
185            .connection
186            .catalog
187            .resolve_journal_path(
188                &self.project.id,
189                self.commit_id,
190                &ResolveJournalPathRequest { path: path.clone() },
191            )
192            .await?;
193        if resolved.commit_id != self.commit_id {
194            return Err(Error::invalid("Catalog changed the selected commit"));
195        }
196        let resource = resolved.resource.ok_or_else(|| {
197            Error::NotFound(
198                path.segments()
199                    .iter()
200                    .map(NamespaceSegment::as_str)
201                    .collect::<Vec<_>>()
202                    .join("/"),
203            )
204        })?;
205        resource.validate().map_err(Error::invalid)?;
206        Ok(resource)
207    }
208
209    #[cfg(feature = "tables")]
210    pub(crate) async fn table_at_path(&self, path: &NamespacePath) -> Result<Table, Error> {
211        let resource = self.resource_at_path(path).await?;
212        if resource.descriptor.kind.0 != "table" || resource.descriptor.service.as_str() != "arbor"
213        {
214            return Err(Error::invalid("resource is not an Arbor table"));
215        }
216        let mut table = self.project.client.table_by_id(
217            resource.descriptor.resource_id.as_uuid(),
218            resource.version_id.as_uuid(),
219        );
220        table.base = Some(CatalogBase {
221            project_id: self.project.id.clone(),
222            commit_id: self.commit_id,
223            branch_id: self.branch_id,
224            version_id: resource.version_id.as_uuid(),
225            address: None,
226        });
227        Ok(table)
228    }
229}
230
231/// The head of a branch, pinned when it was read: a snapshot that a [`Commit`] can publish onto.
232///
233/// The pin is fixed. Publishing returns the new head, and only succeeds while the branch
234/// still points at this one.
235#[derive(Clone)]
236pub struct BranchHead {
237    snapshot: Snapshot,
238    branch_id: ProjectBranchId,
239}
240impl BranchHead {
241    pub(crate) fn new(
242        project: Project,
243        commit_id: ProjectCommitId,
244        branch_id: ProjectBranchId,
245    ) -> Self {
246        Self {
247            snapshot: Snapshot {
248                project,
249                commit_id,
250                branch_id: Some(branch_id),
251            },
252            branch_id,
253        }
254    }
255    /// The head of the branch `snapshot` was pinned from, or `None` for an exact commit.
256    #[cfg(feature = "tables")]
257    pub(crate) fn of(snapshot: &Snapshot) -> Option<Self> {
258        Some(Self {
259            snapshot: snapshot.clone(),
260            branch_id: snapshot.branch_id?,
261        })
262    }
263    /// Stable identity of the branch, which survives renames.
264    pub fn branch_id(&self) -> ProjectBranchId {
265        self.branch_id
266    }
267    /// The pinned head as a read-only snapshot.
268    pub fn snapshot(&self) -> &Snapshot {
269        &self.snapshot
270    }
271    /// Start a commit onto this head.
272    pub fn commit(&self) -> Commit {
273        Commit::new(self.clone())
274    }
275}
276impl Deref for BranchHead {
277    type Target = Snapshot;
278    fn deref(&self) -> &Snapshot {
279        &self.snapshot
280    }
281}
282
283impl Project {
284    /// Read current project metadata.
285    pub async fn get(&self) -> Result<ProjectInfo, Error> {
286        Ok(self
287            .client
288            .projects()
289            .read_project(self.id.as_str().into())
290            .await?)
291    }
292    /// Delete this project. Requires project administration permission.
293    pub async fn delete(&self) -> Result<(), Error> {
294        Ok(self
295            .client
296            .projects()
297            .delete_project(self.id.as_str().into())
298            .await?)
299    }
300    /// List access grants, optionally restricted to one principal.
301    pub fn grants(&self, principal: Option<Principal>) -> List<'_, Grant> {
302        self.client.authorization().list_grants_list(
303            self.id.as_str().into(),
304            ListGrantsRequest {
305                principal,
306                page_size: None,
307                page_token: None,
308            },
309        )
310    }
311    /// Grant a role to a principal on this project.
312    pub async fn grant_role(&self, request: GrantRoleRequest) -> Result<GrantRoleResponse, Error> {
313        Ok(self
314            .client
315            .authorization()
316            .grant_role(self.id.as_str().into(), request)
317            .await?)
318    }
319    /// Read shared presentation state at a key.
320    pub async fn presentation(&self, key: PresentationKey) -> Result<Presentation, Error> {
321        Ok(self
322            .client
323            .projects()
324            .read_presentation(self.id.as_str().into(), key)
325            .await?)
326    }
327    /// Save shared presentation state against its expected revision.
328    pub async fn save_presentation(
329        &self,
330        request: SavePresentation,
331    ) -> Result<Presentation, Error> {
332        Ok(self
333            .client
334            .projects()
335            .save_presentation(self.id.as_str().into(), request)
336            .await?)
337    }
338}