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