Skip to content
931 linesCodeBlameRaw
1//! Folios: what people call artifacts (Artifacts mode, docs/ARTIFACTS_MODE.md).
2//! One mode for docs, slides, designs and dashboards, kept by the docs
3//! service (`services/docs`, TypeScript). Code says "folio"; people see
4//! "artifact" in the UI, URLs, the `artifact` MCP tool, REST paths and the
5//! `artifacts:*` scopes. The Cloudflare Artifacts git store and workflow run
6//! artifacts ([`crate::actions`]) are something else.
7//!
8//! This is the subset of `packages/contracts/src/folios.ts` the Rust API
9//! needs: the shapes it sends and reads back, the arguments of the docs
10//! service's RPC, the validators it runs before calling, and the `folio.*`
11//! event types. Agent edits stay JSON ([`serde_json::Value`]): the docs
12//! service applies them; [`agent_edit_error`] checks their envelope. Wire
13//! shapes are snake_case; the tests keep field names, RPC methods and
14//! validators the same as the TypeScript (`folios.fixtures.json`).
15//!
16//! The API (`apps/api/src/folios.rs`) calls the RPC with these; the docs
17//! service publishes the events.
18
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22use crate::User;
23use crate::datasets::DatasetQuery;
24
25// --- Kinds and roles ----------------------------------------------------------
26
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum FolioKind {
30 Doc,
31 Slides,
32 Design,
33 Dashboard,
34}
35
36impl FolioKind {
37 pub const ALL: [FolioKind; 4] = [FolioKind::Doc, FolioKind::Slides, FolioKind::Design, FolioKind::Dashboard];
38
39 pub fn as_str(self) -> &'static str {
40 match self {
41 FolioKind::Doc => "doc",
42 FolioKind::Slides => "slides",
43 FolioKind::Design => "design",
44 FolioKind::Dashboard => "dashboard",
45 }
46 }
47
48 pub fn parse(text: &str) -> Option<FolioKind> {
49 FolioKind::ALL.into_iter().find(|kind| kind.as_str() == text)
50 }
51
52 /// As the Artifacts home's tiles name it.
53 pub fn label(self) -> &'static str {
54 match self {
55 FolioKind::Doc => "Docs",
56 FolioKind::Slides => "Slides",
57 FolioKind::Design => "Design",
58 FolioKind::Dashboard => "Dashboard",
59 }
60 }
61
62 /// One of it, in a sentence: "a doc", "a deck".
63 pub fn noun(self) -> &'static str {
64 match self {
65 FolioKind::Doc => "doc",
66 FolioKind::Slides => "deck",
67 FolioKind::Design => "design",
68 FolioKind::Dashboard => "dashboard",
69 }
70 }
71
72 /// Only a doc holds other folios.
73 pub fn can_have_children(self) -> bool {
74 self == FolioKind::Doc
75 }
76
77 /// Whether it starts from Markdown (doc, slides) or a JSON spec.
78 pub fn takes_markdown(self) -> bool {
79 matches!(self, FolioKind::Doc | FolioKind::Slides)
80 }
81}
82
83/// What someone may do with a folio, weakest first.
84#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
85#[serde(rename_all = "snake_case")]
86pub enum FolioRole {
87 View,
88 Comment,
89 Edit,
90 Manage,
91}
92
93#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(rename_all = "snake_case")]
95pub enum GeneralAccess {
96 None,
97 Workspace,
98 Link,
99}
100
101impl GeneralAccess {
102 pub fn label(self) -> &'static str {
103 match self {
104 GeneralAccess::None => "Restricted",
105 GeneralAccess::Workspace => "Everyone in the workspace",
106 GeneralAccess::Link => "Anyone in the workspace with the link",
107 }
108 }
109}
110
111/// How agents change a folio: file suggestions (or proposals), or edit.
112#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
113#[serde(rename_all = "snake_case")]
114pub enum AgentMode {
115 Suggest,
116 Edit,
117}
118
119/// The most ops one agent edit carries.
120pub const MAX_OPS: usize = 200;
121/// The longest title, in characters.
122pub const MAX_TITLE: usize = 200;
123/// The most people a folio is shared with in one change.
124pub const MAX_SHARE: usize = 50;
125/// The most folios a page of a list holds.
126pub const LIST_MAX: u32 = 100;
127
128/// Whether `text` names who a grant is for: `user:<id>`, `agent:<id>` or
129/// `team:<slug>`.
130pub fn is_principal(text: &str) -> bool {
131 let Some((kind, id)) = text.split_once(':') else { return false };
132 matches!(kind, "user" | "agent" | "team")
133 && (1..=64).contains(&id.len())
134 && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'.' | b'-'))
135}
136
137/// The folio id at the end of an address segment (`q4-roadmap-fol_…`).
138pub fn folio_id_from(segment: &str) -> Option<&str> {
139 let start = segment.len().checked_sub(30)?;
140 let id = segment.get(start..)?;
141 let suffix = id.strip_prefix("fol_")?;
142 suffix
143 .bytes()
144 .all(|b| b"0123456789abcdefghjkmnpqrstvwxyz".contains(&b))
145 .then_some(id)
146}
147
148// --- Shapes -------------------------------------------------------------------
149
150/// Enough to link to a folio.
151#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
152pub struct FolioRef {
153 pub id: String,
154 pub kind: FolioKind,
155 pub title: String,
156 pub icon: Option<String>,
157 /// `<title-slug>-<id>`.
158 pub slug: String,
159 /// `/<ws>/-/artifacts/<slug>`.
160 pub path: String,
161}
162
163#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
164pub struct FolioSpaceRef {
165 pub id: String,
166 pub slug: String,
167 pub name: String,
168 /// `workspace` (Open), `team` or `private` (Members only).
169 pub kind: String,
170}
171
172#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
173pub struct FolioInheritedFrom {
174 /// `space` or `folio`.
175 pub kind: String,
176 pub id: String,
177 pub name: String,
178}
179
180#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
181pub struct FolioSource {
182 pub title: String,
183 pub href: String,
184}
185
186/// A folio for one viewer. People are member profiles (`MemberProfile` in
187/// chat.ts), kept as JSON.
188#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
189pub struct Folio {
190 #[serde(flatten)]
191 pub folio: FolioRef,
192 pub workspace_id: String,
193 /// None: its owner's Private section.
194 pub space: Option<FolioSpaceRef>,
195 pub parent_id: Option<String>,
196 pub position: f64,
197 pub owner: Value,
198 pub created_by: Value,
199 pub created_at: String,
200 pub updated_at: String,
201 pub edited_by: Option<Value>,
202 pub edited_at: String,
203 pub trashed_at: Option<String>,
204 pub viewer_role: FolioRole,
205 pub favorite: bool,
206 pub private: bool,
207 pub shared_count: u32,
208 pub general_access: GeneralAccess,
209 pub general_role: Option<FolioRole>,
210 pub inherit: bool,
211 pub inherited_from: Option<FolioInheritedFrom>,
212 pub agent_mode: AgentMode,
213 pub excerpt: String,
214 /// What a card draws; never data values.
215 pub preview: Option<Value>,
216 pub source: Option<FolioSource>,
217 pub stale: bool,
218 pub has_children: bool,
219}
220
221#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
222#[serde(rename_all = "snake_case")]
223pub enum FolioListTab {
224 All,
225 Yours,
226 Shared,
227}
228
229#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
230pub struct FolioListQuery {
231 pub tab: FolioListTab,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 pub kinds: Option<Vec<FolioKind>>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub space_id: Option<String>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub owner: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub project: Option<String>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub q: Option<String>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub cursor: Option<String>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub limit: Option<u32>,
246}
247
248impl FolioListQuery {
249 pub fn validate(&self) -> Result<(), String> {
250 match self.limit {
251 Some(limit) if !(1..=LIST_MAX).contains(&limit) => Err(format!("limit is between 1 and {LIST_MAX}.")),
252 _ => Ok(()),
253 }
254 }
255}
256
257#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
258pub struct FolioList {
259 pub items: Vec<Folio>,
260 pub next_cursor: Option<String>,
261}
262
263/// Content to start from: `markdown` for docs and slides, `spec` for
264/// designs and dashboards (one of them; a union in TypeScript).
265#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
266pub struct FolioContentInput {
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub markdown: Option<String>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub spec: Option<Value>,
271}
272
273#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
274pub struct ShareWith {
275 pub principal: String,
276 pub role: FolioRole,
277}
278
279#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
280pub struct NewFolio {
281 pub kind: FolioKind,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub title: Option<String>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub icon: Option<String>,
286 /// None: the creator's Private section.
287 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub space_id: Option<String>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub parent_id: Option<String>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub template_id: Option<String>,
293 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub content: Option<FolioContentInput>,
295 #[serde(default, skip_serializing_if = "Option::is_none")]
296 pub source: Option<FolioSource>,
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub share_with: Option<Vec<ShareWith>>,
299}
300
301impl NewFolio {
302 /// What is wrong with it, in the same words as `newFolioError`.
303 /// Whether its space and parent exist and allow it is the docs
304 /// service's to say.
305 pub fn validate(&self) -> Result<(), String> {
306 if self.title.as_deref().unwrap_or_default().chars().count() > MAX_TITLE {
307 return Err(format!("A title is at most {MAX_TITLE} characters."));
308 }
309 if let Some(content) = &self.content {
310 if self.kind.takes_markdown() && content.markdown.is_none() {
311 return Err(format!("A {} starts from markdown.", self.kind.noun()));
312 }
313 if !self.kind.takes_markdown() && !content.spec.as_ref().is_some_and(Value::is_object) {
314 return Err(format!("A {} starts from a spec.", self.kind.noun()));
315 }
316 if self.template_id.is_some() {
317 return Err("Start from a template or from content, not both.".to_owned());
318 }
319 }
320 let share = self.share_with.as_deref().unwrap_or_default();
321 if share.len() > MAX_SHARE {
322 return Err(format!("Share with at most {MAX_SHARE} at once."));
323 }
324 if let Some(row) = share.iter().find(|row| !is_principal(&row.principal)) {
325 return Err(format!("{} is not user:, agent: or team: and an id.", row.principal));
326 }
327 Ok(())
328 }
329}
330
331#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
332pub struct FolioChange {
333 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub title: Option<String>,
335 /// Some(None) clears it.
336 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub icon: Option<Option<String>>,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
339 pub cover: Option<Option<String>>,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub projects: Option<Vec<String>>,
342}
343
344#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
345pub struct FolioMove {
346 /// None: Private.
347 pub space_id: Option<String>,
348 pub parent_id: Option<String>,
349 #[serde(default, skip_serializing_if = "Option::is_none")]
350 pub before_id: Option<String>,
351}
352
353/// One change from the share dialog.
354#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
355#[serde(tag = "op", rename_all = "snake_case")]
356pub enum FolioAccessChange {
357 Grant {
358 principal: String,
359 role: FolioRole,
360 #[serde(default, skip_serializing_if = "Option::is_none")]
361 notify: Option<String>,
362 },
363 Revoke {
364 principal: String,
365 },
366 General {
367 access: GeneralAccess,
368 role: Option<FolioRole>,
369 },
370 Inherit {
371 inherit: bool,
372 },
373 AgentMode {
374 agent_mode: Option<AgentMode>,
375 },
376}
377
378impl FolioAccessChange {
379 /// What is wrong with it, in the same words as `folioAccessChangeError`.
380 pub fn validate(&self) -> Result<(), String> {
381 match self {
382 FolioAccessChange::Grant { principal, notify, .. } => {
383 if !is_principal(principal) {
384 return Err(format!("{principal} is not user:, agent: or team: and an id."));
385 }
386 if notify.as_deref().is_some_and(|text| text.chars().count() > 2_000) {
387 return Err("A message is at most 2000 characters.".to_owned());
388 }
389 Ok(())
390 }
391 FolioAccessChange::Revoke { principal } if !is_principal(principal) => Err(format!("{principal} is not user:, agent: or team: and an id.")),
392 FolioAccessChange::General { access: GeneralAccess::None, role } => match role {
393 None => Ok(()),
394 Some(_) => Err("Restricted takes no role.".to_owned()),
395 },
396 FolioAccessChange::General { access, role } => match role {
397 None => Err(format!("{} needs a role.", access.label())),
398 Some(FolioRole::Manage) => Err("General access gives view, comment or edit, never full access.".to_owned()),
399 Some(_) => Ok(()),
400 },
401 _ => Ok(()),
402 }
403 }
404
405 /// The docs service method it goes to.
406 pub fn method(&self) -> &'static str {
407 match self {
408 FolioAccessChange::Grant { .. } | FolioAccessChange::Revoke { .. } => "set_folio_grant",
409 _ => "set_folio_general_access",
410 }
411 }
412}
413
414#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
415pub struct FolioVersion {
416 pub id: String,
417 pub folio_id: String,
418 pub created_at: String,
419 pub authors: Vec<Value>,
420 /// created, edit, agent, suggestion, proposal or restore.
421 pub kind: String,
422 pub note: Option<String>,
423}
424
425#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
426pub struct FolioTemplate {
427 pub id: String,
428 pub kind: FolioKind,
429 pub name: String,
430 pub description: String,
431 pub icon: Option<String>,
432 pub builtin: bool,
433 pub body: String,
434 pub created_by: Option<Value>,
435}
436
437// --- Agent edits --------------------------------------------------------------
438
439/// A JavaScript `String(value)` of a field, for messages that must read the
440/// same as the TypeScript's.
441fn js_string(value: Option<&Value>) -> String {
442 match value {
443 None => "undefined".to_owned(),
444 Some(Value::String(text)) => text.clone(),
445 Some(Value::Object(_)) => "[object Object]".to_owned(),
446 Some(Value::Array(items)) => items.iter().map(|item| js_string(Some(item))).collect::<Vec<_>>().join(","),
447 Some(other) => other.to_string(),
448 }
449}
450
451/// What is wrong with an agent edit's envelope (`FolioAgentEdit` in
452/// folios.ts), in the same words as `folioAgentEditError`: its kind, and a
453/// doc's target and Markdown or the other kinds' list of ops. Returns the
454/// kind when it is well formed. Each op is the docs service's to check.
455pub fn agent_edit_error(edit: &Value) -> Result<FolioKind, String> {
456 let Some(edit) = edit.as_object() else { return Err("An edit is an object.".to_owned()) };
457 let Some(kind) = edit.get("kind").and_then(Value::as_str).and_then(FolioKind::parse) else {
458 return Err(format!("There is no kind of artifact called {}.", js_string(edit.get("kind"))));
459 };
460 if edit.get("note").is_some_and(|note| !note.is_null() && !note.is_string()) {
461 return Err("note is text.".to_owned());
462 }
463 if kind == FolioKind::Doc {
464 if !edit.get("markdown").is_some_and(Value::is_string) {
465 return Err("A doc edit has markdown.".to_owned());
466 }
467 if edit.contains_key("ops") {
468 return Err("A doc edit has a target and markdown, not ops.".to_owned());
469 }
470 let Some(target) = edit.get("target").and_then(Value::as_object) else {
471 return Err("A doc edit has a target.".to_owned());
472 };
473 let text = |key: &str| target.get(key).and_then(Value::as_str);
474 return match text("kind") {
475 Some("append" | "document") => Ok(kind),
476 Some("section") if text("heading").is_some_and(|heading| !heading.is_empty()) => Ok(kind),
477 Some("section") => Err("A section target names its heading.".to_owned()),
478 Some("blocks") if text("from_block").is_some() && text("to_block").is_some() => Ok(kind),
479 Some("blocks") => Err("A blocks target names from_block and to_block.".to_owned()),
480 _ => Err("A doc edit's target is append, document, section or blocks.".to_owned()),
481 };
482 }
483 if edit.contains_key("markdown") || edit.contains_key("target") {
484 return Err(format!("A {} edit has ops, not a target and markdown.", kind.noun()));
485 }
486 let Some(ops) = edit.get("ops").and_then(Value::as_array).filter(|ops| !ops.is_empty()) else {
487 return Err(format!("A {} edit has a list of ops.", kind.noun()));
488 };
489 if ops.len() > MAX_OPS {
490 return Err(format!("An edit has at most {MAX_OPS} ops."));
491 }
492 if !ops.iter().all(|op| op.get("op").is_some_and(Value::is_string)) {
493 return Err("Each op is an object with an op.".to_owned());
494 }
495 Ok(kind)
496}
497
498// --- The docs service's folio RPC ---------------------------------------------
499
500/// Every method the docs service answers for folios, as `FOLIO_RPC_METHODS`
501/// in folios.ts.
502pub const FOLIO_RPC_METHODS: [&str; 46] = [
503 "folio_list",
504 "folio_sidebar",
505 "folio",
506 "create_folio",
507 "update_folio",
508 "move_folio",
509 "duplicate_folio",
510 "trash_folio",
511 "restore_folio",
512 "delete_folio",
513 "folio_trash",
514 "favorite_folio",
515 "folio_content",
516 "edit_folio",
517 "folio_access",
518 "set_folio_grant",
519 "set_folio_general_access",
520 "request_folio_access",
521 "join_space",
522 "leave_space",
523 "search_folios",
524 "folio_versions",
525 "folio_version",
526 "restore_folio_version",
527 "folio_templates",
528 "save_folio_template",
529 "delete_folio_template",
530 "export_folio",
531 "folio_suggestions",
532 "decide_folio_suggestion",
533 "folio_proposals",
534 "decide_folio_proposal",
535 "folio_thread",
536 "folio_threads",
537 "query_tile",
538 "query_dataset",
539 "query_dataset_for_agent",
540 "folios_for_agent",
541 "read_folio_for_agent",
542 "create_folio_as_agent",
543 "edit_folio_as_agent",
544 "share_folio_as_agent",
545 "recall_folios_for_agent",
546 "stale_folios_for_agent",
547 "mark_folio_current",
548 "reindex_folios",
549];
550
551/// `folio_list`.
552#[derive(Clone, Debug, Serialize, Deserialize)]
553pub struct FolioListArgs {
554 pub workspace: String,
555 pub viewer: User,
556 pub query: FolioListQuery,
557}
558
559/// `folio`, `duplicate_folio`, `trash_folio`, `restore_folio`,
560/// `delete_folio`, `folio_content`, `folio_access`, `folio_versions`.
561#[derive(Clone, Debug, Serialize, Deserialize)]
562pub struct FolioArgs {
563 pub workspace: String,
564 pub viewer: User,
565 pub folio_id: String,
566}
567
568/// `create_folio`.
569#[derive(Clone, Debug, Serialize, Deserialize)]
570pub struct CreateFolioArgs {
571 pub workspace: String,
572 pub viewer: User,
573 pub input: NewFolio,
574}
575
576/// `update_folio`.
577#[derive(Clone, Debug, Serialize, Deserialize)]
578pub struct UpdateFolioArgs {
579 pub workspace: String,
580 pub viewer: User,
581 pub folio_id: String,
582 pub change: FolioChange,
583}
584
585/// `move_folio`.
586#[derive(Clone, Debug, Serialize, Deserialize)]
587pub struct MoveFolioArgs {
588 pub workspace: String,
589 pub viewer: User,
590 pub folio_id: String,
591 #[serde(rename = "move")]
592 pub to: FolioMove,
593}
594
595/// `edit_folio`: a `FolioAgentEdit`, as JSON.
596#[derive(Clone, Debug, Serialize, Deserialize)]
597pub struct EditFolioArgs {
598 pub workspace: String,
599 pub viewer: User,
600 pub folio_id: String,
601 pub edit: Value,
602}
603
604/// `set_folio_grant` and `set_folio_general_access` (see
605/// [`FolioAccessChange::method`]).
606#[derive(Clone, Debug, Serialize, Deserialize)]
607pub struct FolioAccessArgs {
608 pub workspace: String,
609 pub viewer: User,
610 pub folio_id: String,
611 pub change: FolioAccessChange,
612}
613
614/// `folio_version` and `restore_folio_version`.
615#[derive(Clone, Debug, Serialize, Deserialize)]
616pub struct FolioVersionArgs {
617 pub workspace: String,
618 pub viewer: User,
619 pub folio_id: String,
620 pub version_id: String,
621}
622
623/// `folio_templates`.
624#[derive(Clone, Debug, Serialize, Deserialize)]
625pub struct FolioTemplatesArgs {
626 pub workspace: String,
627 pub viewer: User,
628 pub kind: Option<FolioKind>,
629}
630
631/// `search_folios`.
632#[derive(Clone, Debug, Serialize, Deserialize)]
633pub struct SearchFoliosArgs {
634 pub workspace: String,
635 pub viewer: User,
636 pub query: FolioSearchQuery,
637}
638
639#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
640pub struct FolioSearchQuery {
641 pub q: String,
642 #[serde(default, skip_serializing_if = "Option::is_none")]
643 pub kinds: Option<Vec<FolioKind>>,
644 #[serde(default, skip_serializing_if = "Option::is_none")]
645 pub space_id: Option<String>,
646 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub project: Option<String>,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub owner: Option<String>,
650 /// `words` or `hybrid`.
651 #[serde(default, skip_serializing_if = "Option::is_none")]
652 pub mode: Option<String>,
653 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub limit: Option<u32>,
655}
656
657/// `query_dataset`.
658#[derive(Clone, Debug, Serialize, Deserialize)]
659pub struct QueryDatasetArgs {
660 pub workspace: String,
661 pub viewer: User,
662 pub query: DatasetQuery,
663}
664
665// --- Events -------------------------------------------------------------------
666
667/// The `folio.*` types the docs service publishes, with no `repoId` on
668/// the event: a folio may be private, so it never reaches a repository's
669/// timeline or webhooks. Nothing subscribes to them yet.
670pub const FOLIO_EVENTS: [&str; 6] = ["folio.created", "folio.updated", "folio.trashed", "folio.restored", "folio.shared", "folio.stale"];
671
672/// What every `folio.*` event carries (`FolioEventData` in events.ts).
673/// Event data is camelCase, as every event on the bus.
674#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
675#[serde(rename_all = "camelCase")]
676pub struct FolioEvent {
677 pub workspace: String,
678 pub workspace_id: String,
679 pub folio_id: String,
680 pub kind: FolioKind,
681 pub space_id: Option<String>,
682 /// None unless every member of the workspace can read it.
683 pub title: Option<String>,
684}
685
686/// `folio.updated`: a version.
687#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
688#[serde(rename_all = "camelCase")]
689pub struct FolioUpdated {
690 #[serde(flatten)]
691 pub folio: FolioEvent,
692 pub version_id: String,
693 /// edit, agent, suggestion, proposal or restore.
694 pub version_kind: String,
695 /// Member keys.
696 pub authors: Vec<String>,
697}
698
699/// `folio.shared`: who was given access, never content.
700#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
701#[serde(rename_all = "camelCase")]
702pub struct FolioShared {
703 #[serde(flatten)]
704 pub folio: FolioEvent,
705 pub principals: Vec<String>,
706 pub role: FolioRole,
707}
708
709/// `folio.stale`: code it cites changed.
710#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
711#[serde(rename_all = "camelCase")]
712pub struct FolioStale {
713 #[serde(flatten)]
714 pub folio: FolioEvent,
715 /// `owner/name`.
716 pub repo: String,
717 pub commit: String,
718 pub pull: Option<u32>,
719 pub paths: Vec<String>,
720 pub owners: Vec<String>,
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726 use serde_json::json;
727
728 const TS: &str = include_str!("../../../packages/contracts/src/folios.ts");
729 const EVENTS_TS: &str = include_str!("../../../packages/contracts/src/events.ts");
730
731 fn fixtures() -> Value {
732 serde_json::from_str(include_str!("../../../packages/contracts/src/folios.fixtures.json")).unwrap()
733 }
734
735 /// The body of `export type <name> = ...` up to its closing `};`.
736 fn ts_type<'a>(source: &'a str, name: &str) -> &'a str {
737 let start = format!("export type {name} = ");
738 source
739 .split_once(&start)
740 .and_then(|(_, rest)| rest.split_once("\n};"))
741 .map(|(body, _)| body)
742 .unwrap_or_else(|| panic!("{name} in the TypeScript"))
743 }
744
745 /// Every key `value` serializes to is a field of one of the TypeScript
746 /// types named.
747 fn fields_match(value: &Value, source: &str, types: &[&str]) {
748 let bodies: Vec<&str> = types.iter().map(|name| ts_type(source, name)).collect();
749 for key in value.as_object().unwrap().keys() {
750 assert!(
751 bodies.iter().any(|body| body.contains(&format!(" {key}: ")) || body.contains(&format!(" {key}?: "))),
752 "{key} is not a field of {types:?}"
753 );
754 }
755 }
756
757 fn folio() -> Folio {
758 serde_json::from_value(json!({
759 "id": "fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "kind": "slides", "title": "Q4 roadmap", "icon": null,
760 "slug": "q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "path": "/acme/-/artifacts/q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra",
761 "workspace_id": "wsp_1", "space": { "id": "spc_1", "slug": "general", "name": "General", "kind": "workspace" },
762 "parent_id": null, "position": 1.5,
763 "owner": { "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana" },
764 "created_by": { "kind": "agent", "id": "agt_1", "name": "g1t", "display_name": "g1t" },
765 "created_at": "2026-10-09T00:00:00.000Z", "updated_at": "2026-10-09T00:00:00.000Z",
766 "edited_by": null, "edited_at": "2026-10-09T00:00:00.000Z", "trashed_at": null,
767 "viewer_role": "edit", "favorite": false, "private": false, "shared_count": 2,
768 "general_access": "none", "general_role": null, "inherit": true,
769 "inherited_from": { "kind": "space", "id": "spc_1", "name": "General" }, "agent_mode": "suggest",
770 "excerpt": "", "preview": { "kind": "slides", "aspect": "16:9", "count": 3, "layout": "title", "title": "Q4" },
771 "source": { "title": "#product", "href": "/acme/-/chat/c/1" }, "stale": false, "has_children": false
772 }))
773 .unwrap()
774 }
775
776 #[test]
777 fn shapes_have_the_typescript_field_names() {
778 fields_match(&serde_json::to_value(folio()).unwrap(), TS, &["FolioRef", "Folio"]);
779 let new = NewFolio {
780 kind: FolioKind::Doc,
781 title: Some("Plan".to_owned()),
782 icon: Some("🗺️".to_owned()),
783 space_id: Some("spc_1".to_owned()),
784 parent_id: Some("fol_1".to_owned()),
785 template_id: Some("tpl_1".to_owned()),
786 content: Some(FolioContentInput { markdown: Some("# Plan".to_owned()), spec: None }),
787 source: Some(FolioSource { title: "t".to_owned(), href: "/h".to_owned() }),
788 share_with: Some(vec![ShareWith { principal: "user:usr_2".to_owned(), role: FolioRole::View }]),
789 };
790 fields_match(&serde_json::to_value(&new).unwrap(), TS, &["NewFolio"]);
791 let query = FolioListQuery {
792 tab: FolioListTab::Shared,
793 kinds: Some(vec![FolioKind::Design]),
794 space_id: Some("s".to_owned()),
795 owner: Some("user:u".to_owned()),
796 project: Some("acme/web".to_owned()),
797 q: Some("roadmap".to_owned()),
798 cursor: Some("c".to_owned()),
799 limit: Some(20),
800 };
801 fields_match(&serde_json::to_value(&query).unwrap(), TS, &["FolioListQuery"]);
802 let change = FolioChange { title: Some("x".to_owned()), icon: Some(None), cover: Some(None), projects: Some(vec![]) };
803 fields_match(&serde_json::to_value(&change).unwrap(), TS, &["FolioChange"]);
804 let to = FolioMove { space_id: None, parent_id: None, before_id: Some("fol_2".to_owned()) };
805 assert_eq!(serde_json::to_value(&to).unwrap(), json!({ "space_id": null, "parent_id": null, "before_id": "fol_2" }));
806 let list = FolioList { items: vec![], next_cursor: None };
807 assert_eq!(serde_json::to_value(&list).unwrap(), json!({ "items": [], "next_cursor": null }));
808 let version = FolioVersion { id: "ver_1".to_owned(), folio_id: "fol_1".to_owned(), created_at: "t".to_owned(), authors: vec![], kind: "edit".to_owned(), note: None };
809 fields_match(&serde_json::to_value(&version).unwrap(), TS, &["FolioVersion"]);
810 let template = FolioTemplate {
811 id: "tpl_1".to_owned(),
812 kind: FolioKind::Dashboard,
813 name: "Engineering health".to_owned(),
814 description: String::new(),
815 icon: None,
816 builtin: true,
817 body: "{}".to_owned(),
818 created_by: None,
819 };
820 fields_match(&serde_json::to_value(&template).unwrap(), TS, &["FolioTemplate"]);
821 // Every field of the TypeScript `Folio` comes back from Rust too.
822 let rust: Vec<String> = serde_json::to_value(folio()).unwrap().as_object().unwrap().keys().cloned().collect();
823 for body in [ts_type(TS, "FolioRef"), ts_type(TS, "Folio")] {
824 for line in body.lines() {
825 let line = line.trim_start();
826 if let Some((key, _)) = line.split_once(':').filter(|(key, _)| !key.is_empty() && key.bytes().all(|b| b.is_ascii_lowercase() || b == b'_')) {
827 assert!(rust.contains(&key.to_owned()), "Folio has no {key} in Rust");
828 }
829 }
830 }
831 }
832
833 #[test]
834 fn access_changes_travel_tagged_by_op() {
835 let change = FolioAccessChange::General { access: GeneralAccess::Link, role: Some(FolioRole::Comment) };
836 assert_eq!(serde_json::to_value(&change).unwrap(), json!({ "op": "general", "access": "link", "role": "comment" }));
837 assert_eq!(change.method(), "set_folio_general_access");
838 let grant: FolioAccessChange = serde_json::from_value(json!({ "op": "grant", "principal": "team:design", "role": "edit" })).unwrap();
839 assert_eq!(grant.method(), "set_folio_grant");
840 let mode: FolioAccessChange = serde_json::from_value(json!({ "op": "agent_mode", "agent_mode": "edit" })).unwrap();
841 assert_eq!(mode, FolioAccessChange::AgentMode { agent_mode: Some(AgentMode::Edit) });
842 }
843
844 /// The validators agree with the TypeScript's on the shared fixtures.
845 #[test]
846 fn agrees_with_the_typescript_validators_on_the_fixtures() {
847 let fixtures = fixtures();
848 let check = |case: &Value, outcome: Result<(), String>| match case["error"].as_str() {
849 None => assert_eq!(outcome, Ok(()), "{case}"),
850 Some("*") => assert!(outcome.is_err(), "{case} should be refused"),
851 Some(error) => assert_eq!(outcome, Err(error.to_owned()), "{case}"),
852 };
853 for case in fixtures["new_folio"].as_array().unwrap() {
854 let outcome = serde_json::from_value::<NewFolio>(case["input"].clone()).map_err(|_| "*".to_owned()).and_then(|input| input.validate());
855 check(case, outcome);
856 }
857 for case in fixtures["access_change"].as_array().unwrap() {
858 let outcome = serde_json::from_value::<FolioAccessChange>(case["change"].clone()).map_err(|_| "*".to_owned()).and_then(|change| change.validate());
859 check(case, outcome);
860 }
861 for case in fixtures["agent_edit"].as_array().unwrap() {
862 check(case, agent_edit_error(&case["edit"]).map(|_| ()));
863 }
864 for case in fixtures["folio_ids"].as_array().unwrap() {
865 assert_eq!(folio_id_from(case["segment"].as_str().unwrap()), case["id"].as_str(), "{case}");
866 }
867 }
868
869 #[test]
870 fn the_typescript_mirror_has_the_same_kinds_methods_and_events() {
871 for kind in FolioKind::ALL {
872 assert!(TS.contains(&format!(" {}: \"{}\",", kind.as_str(), kind.label())), "{}", kind.as_str());
873 assert_eq!(serde_json::to_value(kind).unwrap(), kind.as_str());
874 }
875 let methods: Vec<&str> = TS
876 .split_once("export const FOLIO_RPC_METHODS = [")
877 .and_then(|(_, rest)| rest.split_once("] as const"))
878 .map(|(list, _)| list)
879 .expect("FOLIO_RPC_METHODS in folios.ts")
880 .lines()
881 .filter_map(|line| line.trim().strip_prefix('"').and_then(|rest| rest.split_once('"')).map(|(name, _)| name))
882 .collect();
883 assert_eq!(methods, FOLIO_RPC_METHODS);
884 for event in FOLIO_EVENTS {
885 assert!(EVENTS_TS.contains(&format!(" \"{event}\": FolioEventData")), "{event} in events.ts");
886 }
887 let event = FolioEvent {
888 workspace: "acme".to_owned(),
889 workspace_id: "wsp_1".to_owned(),
890 folio_id: "fol_1".to_owned(),
891 kind: FolioKind::Doc,
892 space_id: None,
893 title: None,
894 };
895 fields_match(&serde_json::to_value(&event).unwrap(), EVENTS_TS, &["FolioEventData"]);
896 }
897
898 #[test]
899 fn folio_events_read_as_published_and_name_no_repository_id() {
900 let data = json!({
901 "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "doc", "spaceId": null, "title": null,
902 "repo": "acme/web", "commit": "abc", "pull": 431, "paths": ["src/export.ts"], "owners": ["user:usr_1"]
903 });
904 let stale: FolioStale = serde_json::from_value(data).unwrap();
905 assert_eq!((stale.folio.folio_id.as_str(), stale.pull), ("fol_1", Some(431)));
906 let json = serde_json::to_value(&stale).unwrap();
907 assert!(json.get("repoId").is_none());
908 let shared: FolioShared = serde_json::from_value(json!({
909 "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "slides", "spaceId": "spc_1", "title": "Q4",
910 "principals": ["user:usr_2"], "role": "comment"
911 }))
912 .unwrap();
913 assert_eq!(shared.role, FolioRole::Comment);
914 // Not offered to webhooks.
915 for event in FOLIO_EVENTS {
916 assert!(!crate::webhooks::EVENT_TYPES.contains(&event), "{event}");
917 }
918 }
919
920 #[test]
921 fn principals_and_kinds() {
922 assert!(is_principal("user:usr_01jb"));
923 assert!(is_principal("team:platform-web"));
924 assert!(!is_principal("user:"));
925 assert!(!is_principal("group:x"));
926 assert!(!is_principal("user:a b"));
927 assert!(FolioKind::Doc.can_have_children());
928 assert!(FolioKind::ALL.iter().filter(|kind| kind.can_have_children()).count() == 1);
929 assert!(FolioRole::Manage > FolioRole::Edit && FolioRole::Comment > FolioRole::View);
930 }
931}