Skip to content
933 linesCodeBlameRaw
1//! Folios: what people call artifacts (Artifacts mode).
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; 48] = [
503 "folio_list",
504 "folio_sidebar",
505 "folio",
506 "folio_page",
507 "create_folio",
508 "update_folio",
509 "move_folio",
510 "duplicate_folio",
511 "trash_folio",
512 "restore_folio",
513 "delete_folio",
514 "folio_trash",
515 "favorite_folio",
516 "folio_content",
517 "edit_folio",
518 "folio_access",
519 "set_folio_grant",
520 "set_folio_general_access",
521 "request_folio_access",
522 "join_space",
523 "leave_space",
524 "search_folios",
525 "folio_versions",
526 "folio_version",
527 "restore_folio_version",
528 "folio_templates",
529 "save_folio_template",
530 "delete_folio_template",
531 "export_folio",
532 "folio_suggestions",
533 "decide_folio_suggestion",
534 "folio_proposals",
535 "decide_folio_proposal",
536 "folio_thread",
537 "folio_threads",
538 "query_tile",
539 "query_dataset",
540 "query_dataset_for_agent",
541 "folios_for_agent",
542 "read_folio_for_agent",
543 "create_folio_as_agent",
544 "edit_folio_as_agent",
545 "share_folio_as_agent",
546 "attach_file_as_agent",
547 "recall_folios_for_agent",
548 "stale_folios_for_agent",
549 "mark_folio_current",
550 "reindex_folios",
551];
552
553/// `folio_list`.
554#[derive(Clone, Debug, Serialize, Deserialize)]
555pub struct FolioListArgs {
556 pub workspace: String,
557 pub viewer: User,
558 pub query: FolioListQuery,
559}
560
561/// `folio`, `duplicate_folio`, `trash_folio`, `restore_folio`,
562/// `delete_folio`, `folio_content`, `folio_access`, `folio_versions`.
563#[derive(Clone, Debug, Serialize, Deserialize)]
564pub struct FolioArgs {
565 pub workspace: String,
566 pub viewer: User,
567 pub folio_id: String,
568}
569
570/// `create_folio`.
571#[derive(Clone, Debug, Serialize, Deserialize)]
572pub struct CreateFolioArgs {
573 pub workspace: String,
574 pub viewer: User,
575 pub input: NewFolio,
576}
577
578/// `update_folio`.
579#[derive(Clone, Debug, Serialize, Deserialize)]
580pub struct UpdateFolioArgs {
581 pub workspace: String,
582 pub viewer: User,
583 pub folio_id: String,
584 pub change: FolioChange,
585}
586
587/// `move_folio`.
588#[derive(Clone, Debug, Serialize, Deserialize)]
589pub struct MoveFolioArgs {
590 pub workspace: String,
591 pub viewer: User,
592 pub folio_id: String,
593 #[serde(rename = "move")]
594 pub to: FolioMove,
595}
596
597/// `edit_folio`: a `FolioAgentEdit`, as JSON.
598#[derive(Clone, Debug, Serialize, Deserialize)]
599pub struct EditFolioArgs {
600 pub workspace: String,
601 pub viewer: User,
602 pub folio_id: String,
603 pub edit: Value,
604}
605
606/// `set_folio_grant` and `set_folio_general_access` (see
607/// [`FolioAccessChange::method`]).
608#[derive(Clone, Debug, Serialize, Deserialize)]
609pub struct FolioAccessArgs {
610 pub workspace: String,
611 pub viewer: User,
612 pub folio_id: String,
613 pub change: FolioAccessChange,
614}
615
616/// `folio_version` and `restore_folio_version`.
617#[derive(Clone, Debug, Serialize, Deserialize)]
618pub struct FolioVersionArgs {
619 pub workspace: String,
620 pub viewer: User,
621 pub folio_id: String,
622 pub version_id: String,
623}
624
625/// `folio_templates`.
626#[derive(Clone, Debug, Serialize, Deserialize)]
627pub struct FolioTemplatesArgs {
628 pub workspace: String,
629 pub viewer: User,
630 pub kind: Option<FolioKind>,
631}
632
633/// `search_folios`.
634#[derive(Clone, Debug, Serialize, Deserialize)]
635pub struct SearchFoliosArgs {
636 pub workspace: String,
637 pub viewer: User,
638 pub query: FolioSearchQuery,
639}
640
641#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
642pub struct FolioSearchQuery {
643 pub q: String,
644 #[serde(default, skip_serializing_if = "Option::is_none")]
645 pub kinds: Option<Vec<FolioKind>>,
646 #[serde(default, skip_serializing_if = "Option::is_none")]
647 pub space_id: Option<String>,
648 #[serde(default, skip_serializing_if = "Option::is_none")]
649 pub project: Option<String>,
650 #[serde(default, skip_serializing_if = "Option::is_none")]
651 pub owner: Option<String>,
652 /// `words` or `hybrid`.
653 #[serde(default, skip_serializing_if = "Option::is_none")]
654 pub mode: Option<String>,
655 #[serde(default, skip_serializing_if = "Option::is_none")]
656 pub limit: Option<u32>,
657}
658
659/// `query_dataset`.
660#[derive(Clone, Debug, Serialize, Deserialize)]
661pub struct QueryDatasetArgs {
662 pub workspace: String,
663 pub viewer: User,
664 pub query: DatasetQuery,
665}
666
667// --- Events -------------------------------------------------------------------
668
669/// The `folio.*` types the docs service publishes, with no `repoId` on
670/// the event: a folio may be private, so it never reaches a repository's
671/// timeline or webhooks. Nothing subscribes to them yet.
672pub const FOLIO_EVENTS: [&str; 6] = ["folio.created", "folio.updated", "folio.trashed", "folio.restored", "folio.shared", "folio.stale"];
673
674/// What every `folio.*` event carries (`FolioEventData` in events.ts).
675/// Event data is camelCase, as every event on the bus.
676#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
677#[serde(rename_all = "camelCase")]
678pub struct FolioEvent {
679 pub workspace: String,
680 pub workspace_id: String,
681 pub folio_id: String,
682 pub kind: FolioKind,
683 pub space_id: Option<String>,
684 /// None unless every member of the workspace can read it.
685 pub title: Option<String>,
686}
687
688/// `folio.updated`: a version.
689#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
690#[serde(rename_all = "camelCase")]
691pub struct FolioUpdated {
692 #[serde(flatten)]
693 pub folio: FolioEvent,
694 pub version_id: String,
695 /// edit, agent, suggestion, proposal or restore.
696 pub version_kind: String,
697 /// Member keys.
698 pub authors: Vec<String>,
699}
700
701/// `folio.shared`: who was given access, never content.
702#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
703#[serde(rename_all = "camelCase")]
704pub struct FolioShared {
705 #[serde(flatten)]
706 pub folio: FolioEvent,
707 pub principals: Vec<String>,
708 pub role: FolioRole,
709}
710
711/// `folio.stale`: code it cites changed.
712#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
713#[serde(rename_all = "camelCase")]
714pub struct FolioStale {
715 #[serde(flatten)]
716 pub folio: FolioEvent,
717 /// `owner/name`.
718 pub repo: String,
719 pub commit: String,
720 pub pull: Option<u32>,
721 pub paths: Vec<String>,
722 pub owners: Vec<String>,
723}
724
725#[cfg(test)]
726mod tests {
727 use super::*;
728 use serde_json::json;
729
730 const TS: &str = include_str!("../../../packages/contracts/src/folios.ts");
731 const EVENTS_TS: &str = include_str!("../../../packages/contracts/src/events.ts");
732
733 fn fixtures() -> Value {
734 serde_json::from_str(include_str!("../../../packages/contracts/src/folios.fixtures.json")).unwrap()
735 }
736
737 /// The body of `export type <name> = ...` up to its closing `};`.
738 fn ts_type<'a>(source: &'a str, name: &str) -> &'a str {
739 let start = format!("export type {name} = ");
740 source
741 .split_once(&start)
742 .and_then(|(_, rest)| rest.split_once("\n};"))
743 .map(|(body, _)| body)
744 .unwrap_or_else(|| panic!("{name} in the TypeScript"))
745 }
746
747 /// Every key `value` serializes to is a field of one of the TypeScript
748 /// types named.
749 fn fields_match(value: &Value, source: &str, types: &[&str]) {
750 let bodies: Vec<&str> = types.iter().map(|name| ts_type(source, name)).collect();
751 for key in value.as_object().unwrap().keys() {
752 assert!(
753 bodies.iter().any(|body| body.contains(&format!(" {key}: ")) || body.contains(&format!(" {key}?: "))),
754 "{key} is not a field of {types:?}"
755 );
756 }
757 }
758
759 fn folio() -> Folio {
760 serde_json::from_value(json!({
761 "id": "fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "kind": "slides", "title": "Q4 roadmap", "icon": null,
762 "slug": "q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra", "path": "/acme/-/artifacts/q4-roadmap-fol_01jb2k7x9hfq0b3zj0f5s2m8ra",
763 "workspace_id": "wsp_1", "space": { "id": "spc_1", "slug": "general", "name": "General", "kind": "workspace" },
764 "parent_id": null, "position": 1.5,
765 "owner": { "kind": "user", "id": "usr_1", "name": "ana", "display_name": "Ana" },
766 "created_by": { "kind": "agent", "id": "agt_1", "name": "g1t", "display_name": "g1t" },
767 "created_at": "2026-10-09T00:00:00.000Z", "updated_at": "2026-10-09T00:00:00.000Z",
768 "edited_by": null, "edited_at": "2026-10-09T00:00:00.000Z", "trashed_at": null,
769 "viewer_role": "edit", "favorite": false, "private": false, "shared_count": 2,
770 "general_access": "none", "general_role": null, "inherit": true,
771 "inherited_from": { "kind": "space", "id": "spc_1", "name": "General" }, "agent_mode": "suggest",
772 "excerpt": "", "preview": { "kind": "slides", "aspect": "16:9", "count": 3, "layout": "title", "title": "Q4" },
773 "source": { "title": "#product", "href": "/acme/-/chat/c/1" }, "stale": false, "has_children": false
774 }))
775 .unwrap()
776 }
777
778 #[test]
779 fn shapes_have_the_typescript_field_names() {
780 fields_match(&serde_json::to_value(folio()).unwrap(), TS, &["FolioRef", "Folio"]);
781 let new = NewFolio {
782 kind: FolioKind::Doc,
783 title: Some("Plan".to_owned()),
784 icon: Some("🗺️".to_owned()),
785 space_id: Some("spc_1".to_owned()),
786 parent_id: Some("fol_1".to_owned()),
787 template_id: Some("tpl_1".to_owned()),
788 content: Some(FolioContentInput { markdown: Some("# Plan".to_owned()), spec: None }),
789 source: Some(FolioSource { title: "t".to_owned(), href: "/h".to_owned() }),
790 share_with: Some(vec![ShareWith { principal: "user:usr_2".to_owned(), role: FolioRole::View }]),
791 };
792 fields_match(&serde_json::to_value(&new).unwrap(), TS, &["NewFolio"]);
793 let query = FolioListQuery {
794 tab: FolioListTab::Shared,
795 kinds: Some(vec![FolioKind::Design]),
796 space_id: Some("s".to_owned()),
797 owner: Some("user:u".to_owned()),
798 project: Some("acme/web".to_owned()),
799 q: Some("roadmap".to_owned()),
800 cursor: Some("c".to_owned()),
801 limit: Some(20),
802 };
803 fields_match(&serde_json::to_value(&query).unwrap(), TS, &["FolioListQuery"]);
804 let change = FolioChange { title: Some("x".to_owned()), icon: Some(None), cover: Some(None), projects: Some(vec![]) };
805 fields_match(&serde_json::to_value(&change).unwrap(), TS, &["FolioChange"]);
806 let to = FolioMove { space_id: None, parent_id: None, before_id: Some("fol_2".to_owned()) };
807 assert_eq!(serde_json::to_value(&to).unwrap(), json!({ "space_id": null, "parent_id": null, "before_id": "fol_2" }));
808 let list = FolioList { items: vec![], next_cursor: None };
809 assert_eq!(serde_json::to_value(&list).unwrap(), json!({ "items": [], "next_cursor": null }));
810 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 };
811 fields_match(&serde_json::to_value(&version).unwrap(), TS, &["FolioVersion"]);
812 let template = FolioTemplate {
813 id: "tpl_1".to_owned(),
814 kind: FolioKind::Dashboard,
815 name: "Engineering health".to_owned(),
816 description: String::new(),
817 icon: None,
818 builtin: true,
819 body: "{}".to_owned(),
820 created_by: None,
821 };
822 fields_match(&serde_json::to_value(&template).unwrap(), TS, &["FolioTemplate"]);
823 // Every field of the TypeScript `Folio` comes back from Rust too.
824 let rust: Vec<String> = serde_json::to_value(folio()).unwrap().as_object().unwrap().keys().cloned().collect();
825 for body in [ts_type(TS, "FolioRef"), ts_type(TS, "Folio")] {
826 for line in body.lines() {
827 let line = line.trim_start();
828 if let Some((key, _)) = line.split_once(':').filter(|(key, _)| !key.is_empty() && key.bytes().all(|b| b.is_ascii_lowercase() || b == b'_')) {
829 assert!(rust.contains(&key.to_owned()), "Folio has no {key} in Rust");
830 }
831 }
832 }
833 }
834
835 #[test]
836 fn access_changes_travel_tagged_by_op() {
837 let change = FolioAccessChange::General { access: GeneralAccess::Link, role: Some(FolioRole::Comment) };
838 assert_eq!(serde_json::to_value(&change).unwrap(), json!({ "op": "general", "access": "link", "role": "comment" }));
839 assert_eq!(change.method(), "set_folio_general_access");
840 let grant: FolioAccessChange = serde_json::from_value(json!({ "op": "grant", "principal": "team:design", "role": "edit" })).unwrap();
841 assert_eq!(grant.method(), "set_folio_grant");
842 let mode: FolioAccessChange = serde_json::from_value(json!({ "op": "agent_mode", "agent_mode": "edit" })).unwrap();
843 assert_eq!(mode, FolioAccessChange::AgentMode { agent_mode: Some(AgentMode::Edit) });
844 }
845
846 /// The validators agree with the TypeScript's on the shared fixtures.
847 #[test]
848 fn agrees_with_the_typescript_validators_on_the_fixtures() {
849 let fixtures = fixtures();
850 let check = |case: &Value, outcome: Result<(), String>| match case["error"].as_str() {
851 None => assert_eq!(outcome, Ok(()), "{case}"),
852 Some("*") => assert!(outcome.is_err(), "{case} should be refused"),
853 Some(error) => assert_eq!(outcome, Err(error.to_owned()), "{case}"),
854 };
855 for case in fixtures["new_folio"].as_array().unwrap() {
856 let outcome = serde_json::from_value::<NewFolio>(case["input"].clone()).map_err(|_| "*".to_owned()).and_then(|input| input.validate());
857 check(case, outcome);
858 }
859 for case in fixtures["access_change"].as_array().unwrap() {
860 let outcome = serde_json::from_value::<FolioAccessChange>(case["change"].clone()).map_err(|_| "*".to_owned()).and_then(|change| change.validate());
861 check(case, outcome);
862 }
863 for case in fixtures["agent_edit"].as_array().unwrap() {
864 check(case, agent_edit_error(&case["edit"]).map(|_| ()));
865 }
866 for case in fixtures["folio_ids"].as_array().unwrap() {
867 assert_eq!(folio_id_from(case["segment"].as_str().unwrap()), case["id"].as_str(), "{case}");
868 }
869 }
870
871 #[test]
872 fn the_typescript_mirror_has_the_same_kinds_methods_and_events() {
873 for kind in FolioKind::ALL {
874 assert!(TS.contains(&format!(" {}: \"{}\",", kind.as_str(), kind.label())), "{}", kind.as_str());
875 assert_eq!(serde_json::to_value(kind).unwrap(), kind.as_str());
876 }
877 let methods: Vec<&str> = TS
878 .split_once("export const FOLIO_RPC_METHODS = [")
879 .and_then(|(_, rest)| rest.split_once("] as const"))
880 .map(|(list, _)| list)
881 .expect("FOLIO_RPC_METHODS in folios.ts")
882 .lines()
883 .filter_map(|line| line.trim().strip_prefix('"').and_then(|rest| rest.split_once('"')).map(|(name, _)| name))
884 .collect();
885 assert_eq!(methods, FOLIO_RPC_METHODS);
886 for event in FOLIO_EVENTS {
887 assert!(EVENTS_TS.contains(&format!(" \"{event}\": FolioEventData")), "{event} in events.ts");
888 }
889 let event = FolioEvent {
890 workspace: "acme".to_owned(),
891 workspace_id: "wsp_1".to_owned(),
892 folio_id: "fol_1".to_owned(),
893 kind: FolioKind::Doc,
894 space_id: None,
895 title: None,
896 };
897 fields_match(&serde_json::to_value(&event).unwrap(), EVENTS_TS, &["FolioEventData"]);
898 }
899
900 #[test]
901 fn folio_events_read_as_published_and_name_no_repository_id() {
902 let data = json!({
903 "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "doc", "spaceId": null, "title": null,
904 "repo": "acme/web", "commit": "abc", "pull": 431, "paths": ["src/export.ts"], "owners": ["user:usr_1"]
905 });
906 let stale: FolioStale = serde_json::from_value(data).unwrap();
907 assert_eq!((stale.folio.folio_id.as_str(), stale.pull), ("fol_1", Some(431)));
908 let json = serde_json::to_value(&stale).unwrap();
909 assert!(json.get("repoId").is_none());
910 let shared: FolioShared = serde_json::from_value(json!({
911 "workspace": "acme", "workspaceId": "wsp_1", "folioId": "fol_1", "kind": "slides", "spaceId": "spc_1", "title": "Q4",
912 "principals": ["user:usr_2"], "role": "comment"
913 }))
914 .unwrap();
915 assert_eq!(shared.role, FolioRole::Comment);
916 // Not offered to webhooks.
917 for event in FOLIO_EVENTS {
918 assert!(!crate::webhooks::EVENT_TYPES.contains(&event), "{event}");
919 }
920 }
921
922 #[test]
923 fn principals_and_kinds() {
924 assert!(is_principal("user:usr_01jb"));
925 assert!(is_principal("team:platform-web"));
926 assert!(!is_principal("user:"));
927 assert!(!is_principal("group:x"));
928 assert!(!is_principal("user:a b"));
929 assert!(FolioKind::Doc.can_have_children());
930 assert!(FolioKind::ALL.iter().filter(|kind| kind.can_have_children()).count() == 1);
931 assert!(FolioRole::Manage > FolioRole::Edit && FolioRole::Comment > FolioRole::View);
932 }
933}