Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/src/srv/publish/dest.rs

20.8 KiB, 82 runs

created by r1870400018:14743, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! Where a post goes beyond the site's own pages.
2//!
3//! The site's own pages are the *origin*: a local write to the store that either happens or does not,
4//! and cannot half-succeed. Everywhere else -- a Mastodon server, a Bluesky PDS, an inbox -- is a
5//! [`Delivery`]: an unreliable network reached over time, with its own state, its own retries and its
6//! own returned address. So a post carries one [`PostState`](super::PostState) for itself and a
7//! [`Delivery`] for each remote, and the two are not the same kind of thing.
8//!
9//! # A destination is described, not hard-coded into each caller
10//!
11//! Each [`Destination`] answers a [`Capability`]: how long a body it takes, whether it carries a link
12//! without penalty, whether media can ride along, and what a post there costs. The composer reads the
13//! capability to derive a default [`Rendition`] and to count length as the author types; the sender
14//! reads it to know what it may send. A new destination is a new variant and a new capability, and
15//! nothing that already works has to change -- which is the test the design is meant to pass.
16//!
17//! # This module is data, not delivery
18//!
19//! Everything here is a pure value: the enum, the capability, the rendition, the delivery state and
20//! the retry policy. Nothing here opens a socket or reads a clock. The sender fills a
21//! [`DeliveryState::Sent`] with the moment and the permalink the remote returned; the worker consults
22//! [`DeliveryState::backoff_secs`] against a clock it owns. Keeping the model clock-free is what lets
23//! it be tested without either.
24//!
25//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
26//! Anthropic Claude
27
28use oxedyne_fe2o3_core::prelude::*;
29use oxedyne_fe2o3_jdat::prelude::*;
30
31
32/// A place a post is delivered to, besides the site's own pages.
33///
34/// The own site is not here: it is the origin, a store write, not a delivery over a network. A
35/// `Destination` is always a remote, and always something that can be slow, refuse, or vanish.
36#[derive(Clone, Copy, Debug, Eq, PartialEq)]
37pub enum Destination {
38 Email, // subscribers, through the site's own DKIM sender
39 Mastodon, // a static bearer token
40 Bluesky, // an app password exchanged for a session
41 // Per-post has no sanctioned API and rides an unofficial one; the supported path is a bulk feed
42 // import, so this is named but not wired.
43 Substack,
44 X, // pay-per-use, behind an OAuth 2.0 client
45 Threads, // behind Meta's OAuth and app review
46}
47
48impl Destination {
49
50 // Every destination the module names, in the order a picker shows them: the free and wired
51 // first, the costed and the unbuilt after.
52 pub const ALL: [Self; 6] = [
53 Self::Email,
54 Self::Mastodon,
55 Self::Bluesky,
56 Self::Substack,
57 Self::X,
58 Self::Threads,
59 ];
60
61 /// The word a record stores.
62 pub fn as_str(&self) -> &'static str {
63 match self {
64 Self::Email => "email",
65 Self::Mastodon => "mastodon",
66 Self::Bluesky => "bluesky",
67 Self::Substack => "substack",
68 Self::X => "x",
69 Self::Threads => "threads",
70 }
71 }
72
73 /// The destination a word names, or nothing where the word is one this version does not know.
74 ///
75 /// Not a lenient default, and not an error either. A record naming a destination this version
76 /// cannot place is a record a later version wrote, and the safe reading is to drop *that delivery*
77 /// -- never to guess a different remote (which would post to the wrong place) and never to refuse
78 /// the whole post (which would make every new destination a migration). The caller skips a `None`.
79 pub fn of(s: &str) -> Option<Self> {
80 match s {
81 "email" => Some(Self::Email),
82 "mastodon" => Some(Self::Mastodon),
83 "bluesky" => Some(Self::Bluesky),
84 "substack" => Some(Self::Substack),
85 "x" => Some(Self::X),
86 "threads" => Some(Self::Threads),
87 _ => None,
88 }
89 }
90
91 pub fn capability(&self) -> Capability {
92 match self {
93 // The newsletter is the whole post, not a blurb, so it has no length worth enforcing here.
94 // `wired` stays false because email is not a per-remote queue delivery with a single
95 // permalink to return: it is a fan-out to the site's own subscriber list, sent from the
96 // subscribers page (`/manage/subscribers`) through the site's DKIM sender, not queued as a
97 // `Delivery` on the post the way Mastodon and Bluesky are.
98 Self::Email => Capability {
99 name: "Email",
100 max_chars: None,
101 links: true,
102 media: true,
103 cost_micros: 0,
104 link_micros: 0,
105 wired: false,
106 },
107 Self::Mastodon => Capability {
108 name: "Mastodon",
109 max_chars: Some(500),
110 links: true,
111 media: true,
112 cost_micros: 0,
113 link_micros: 0,
114 wired: false,
115 },
116 Self::Bluesky => Capability {
117 name: "Bluesky",
118 max_chars: Some(300),
119 links: true,
120 media: true,
121 cost_micros: 0,
122 link_micros: 0,
123 wired: false,
124 },
125 // The bulk feed import carries the whole post and needs no per-post rendition; the per-post
126 // unofficial API is deferred, so nothing here is wired.
127 Self::Substack => Capability {
128 name: "Substack",
129 max_chars: None,
130 links: true,
131 media: true,
132 cost_micros: 0,
133 link_micros: 0,
134 wired: false,
135 },
136 // $0.015 a post, and $0.20 when the post carries a link: the base is 15,000 micros and the
137 // link adds 185,000 more, to 200,000.
138 Self::X => Capability {
139 name: "X",
140 max_chars: Some(280),
141 links: true,
142 media: true,
143 cost_micros: 15_000,
144 link_micros: 185_000,
145 wired: false,
146 },
147 Self::Threads => Capability {
148 name: "Threads",
149 max_chars: Some(500),
150 links: true,
151 media: true,
152 cost_micros: 0,
153 link_micros: 0,
154 wired: false,
155 },
156 }
157 }
158}
159
160
161/// What a destination will take, and what it costs.
162///
163/// Read by the composer to derive a default rendition and to count length as an author types, and by
164/// the sender to know what it may send. Every field is a fact about the remote, not a preference of the
165/// site's.
166#[derive(Clone, Copy, Debug, Eq, PartialEq)]
167pub struct Capability {
168 pub name: &'static str, // what a picker shows
169 // The longest body the remote accepts, in characters. Nothing where the limit is not worth
170 // enforcing here: an email carries the whole post.
171 pub max_chars: Option<usize>,
172 pub links: bool, // carried as written, not stripped or surcharged
173 pub media: bool, // an image can ride along with the words
174 pub cost_micros: u64, // millionths of a US dollar; zero for the free remotes
175 pub link_micros: u64, // extra millionths where the body carries a link
176 // Whether this module can actually deliver to the remote yet, or only names it. A picker greys
177 // out what is not wired rather than offering a post that will never go.
178 pub wired: bool,
179}
180
181impl Capability {
182
183 /// What a post costs here, given whether its body carries a link, in millionths of a US dollar.
184 pub fn cost_of(&self, has_link: bool) -> u64 {
185 if has_link {
186 self.cost_micros + self.link_micros
187 } else {
188 self.cost_micros
189 }
190 }
191}
192
193
194/// The words a destination gets, as against the words the site's own page gets.
195///
196/// A remote is not the site: a 280-character timeline cannot take a 2,000-word essay, and pushing one
197/// there unedited reads as a machine, which costs the following the post was written to build. So each
198/// delivery carries its own rendition -- derived by default so nothing has to be typed twice, editable
199/// so nothing reads like a bot, and remembered once edited so a hand-written one is not overwritten by
200/// the next automatic pass.
201#[derive(Clone, Debug, Default, Eq, PartialEq)]
202pub struct Rendition {
203 pub text: String, // the body this destination is sent
204 // Whether this is still the derived default (`true`) or has been edited by hand (`false`). The
205 // distinction is the whole reason renditions are remembered: an automatic pass may replace an
206 // automatic rendition and must never replace a hand-written one.
207 pub auto: bool,
208}
209
210impl Rendition {
211
212 /// The default body for a length-limited social destination: the post's title and its canonical
213 /// link, trimmed to fit.
214 ///
215 /// The link is kept whole -- a truncated URL is a broken one -- and the title gives way to make
216 /// room. Where even the link alone will not fit, the link alone is sent, because a link that works
217 /// is worth more than a title that is cut off before it.
218 pub fn promo(title: &str, url: &str, max_chars: Option<usize>) -> Self {
219 let joined = |t: &str| -> String {
220 if t.is_empty() {
221 url.to_string()
222 } else {
223 fmt!("{}\n\n{}", t, url)
224 }
225 };
226 let text = match max_chars {
227 None => joined(title),
228 Some(max) => {
229 let full = joined(title);
230 if full.chars().count() <= max {
231 full
232 } else {
233 // Reserve the link and the two newlines before it, and give the rest to the title.
234 let url_len = url.chars().count();
235 let reserve = url_len + 2;
236 if reserve >= max {
237 // Not even the link fits with a title; send the link on its own.
238 url.to_string()
239 } else {
240 let room = max - reserve;
241 // A trimmed title ends on an ellipsis, so leave a place for it.
242 let keep = room.saturating_sub(1);
243 let cut = title.char_indices()
244 .take(keep)
245 .filter(|(_, c)| c.is_whitespace())
246 .map(|(i, _)| i)
247 .last()
248 .unwrap_or_else(|| title.char_indices()
249 .nth(keep)
250 .map(|(i, _)| i)
251 .unwrap_or(title.len()));
252 let mut t = title[..cut].trim_end().to_string();
253 t.push('…');
254 joined(&t)
255 }
256 }
257 }
258 };
259 Self { text, auto: true }
260 }
261
262 pub fn to_dat(&self) -> Dat {
263 let mut m = DaticleMap::new();
264 m.insert(dat!("text"), dat!(self.text.clone()));
265 m.insert(dat!("auto"), Dat::Bool(self.auto));
266 Dat::Map(m)
267 }
268
269 /// The rendition from a daticle. A missing or ill-typed field takes its default -- an absent body
270 /// is the empty one, and an absent `auto` reads as a default still (the safe reading: an automatic
271 /// pass may overwrite it, where treating an unknown as hand-written would freeze a bad default).
272 pub fn from_dat(d: &Dat) -> Self {
273 let m = match d {
274 Dat::Map(m) => m,
275 _ => return Self::default(),
276 };
277 let text = match m.get(&dat!("text")) {
278 Some(Dat::Str(s)) => s.clone(),
279 _ => String::new(),
280 };
281 let auto = match m.get(&dat!("auto")) {
282 Some(Dat::Bool(b)) => *b,
283 _ => true,
284 };
285 Self { text, auto }
286 }
287}
288
289
290// A remote that has refused this many times is not going to take the post because it was asked
291// once more, and a queue that retries for ever is a queue that never drains. The number is
292// arbitrary; having one, so the queue is bounded, is not.
293pub const MAX_RETRIES: u32 = 5;
294
295// The wait after a failure doubles each time until it stops at the cap, so a remote briefly down
296// is retried soon and a stubborn one hourly rather than never.
297pub const BACKOFF_BASE_SECS: u64 = 60;
298pub const BACKOFF_CAP_SECS: u64 = 3600;
299
300
301/// Where one delivery to one remote has got to.
302///
303/// The state of the post at the remote, as against [`PostState`](super::PostState), which is the state
304/// of the post at home. A remote can have taken a post the site still calls a draft, or refused one it
305/// calls live: the two states are about different places and do not track each other.
306#[derive(Clone, Debug, Eq, PartialEq)]
307pub enum DeliveryState {
308 Queued, // waiting for the worker to try it, or to try it again
309 // Taken. `at` is the moment the remote confirmed it, `permalink` the address it gave back --
310 // kept so the site can say "also on Mastodon" and link to where the post actually landed.
311 Sent {
312 at: String,
313 permalink: String,
314 },
315 // Refused. `at` is when, `err` is what the remote or the wire said, `retries` how many attempts
316 // have failed. At MAX_RETRIES the worker stops trying and the failure stands.
317 Failed {
318 at: String,
319 err: String,
320 retries: u32,
321 },
322}
323
324impl DeliveryState {
325
326 /// How long to wait before the next attempt, given how many have already failed.
327 ///
328 /// Exponential from [`BACKOFF_BASE_SECS`], doubling each failure, capped at [`BACKOFF_CAP_SECS`]:
329 /// a remote briefly down is retried soon, a remote long down is not hammered. Pure, so the worker
330 /// supplies the clock and this supplies only the interval.
331 pub fn backoff_secs(retries: u32) -> u64 {
332 let shifted = BACKOFF_BASE_SECS.checked_shl(retries).unwrap_or(u64::MAX);
333 shifted.min(BACKOFF_CAP_SECS)
334 }
335
336 /// Whether the worker is done with this delivery -- it has been taken, or refused past retrying.
337 pub fn is_terminal(&self) -> bool {
338 match self {
339 Self::Queued => false,
340 Self::Sent { .. } => true,
341 Self::Failed { retries, .. } => *retries >= MAX_RETRIES,
342 }
343 }
344
345 fn tag(&self) -> &'static str {
346 match self {
347 Self::Queued => "queued",
348 Self::Sent { .. } => "sent",
349 Self::Failed { .. } => "failed",
350 }
351 }
352
353 pub fn to_dat(&self) -> Dat {
354 let mut m = DaticleMap::new();
355 m.insert(dat!("state"), dat!(self.tag().to_string()));
356 match self {
357 Self::Queued => {},
358 Self::Sent { at, permalink } => {
359 m.insert(dat!("at"), dat!(at.clone()));
360 m.insert(dat!("permalink"), dat!(permalink.clone()));
361 }
362 Self::Failed { at, err, retries } => {
363 m.insert(dat!("at"), dat!(at.clone()));
364 m.insert(dat!("err"), dat!(err.clone()));
365 m.insert(dat!("retries"), Dat::U32(*retries));
366 }
367 }
368 Dat::Map(m)
369 }
370
371 /// The state from a daticle.
372 ///
373 /// A state word this version cannot read is **not** taken as queued, because a queued delivery is
374 /// re-sent and a post this version cannot understand the state of might already have gone -- sending
375 /// it again would double-post. So an unreadable state is a spent failure: inert, retried by nobody,
376 /// and visible as a failure to whoever looks.
377 pub fn from_dat(d: &Dat) -> Self {
378 let m = match d {
379 Dat::Map(m) => m,
380 _ => return Self::spent("a delivery state that is not a map"),
381 };
382 let tag = match m.get(&dat!("state")) {
383 Some(Dat::Str(s)) => s.clone(),
384 _ => return Self::spent("a delivery with no state"),
385 };
386 let get_str = |key: &str| -> String {
387 match m.get(&dat!(key)) {
388 Some(Dat::Str(s)) => s.clone(),
389 _ => String::new(),
390 }
391 };
392 match tag.as_str() {
393 "queued" => Self::Queued,
394 "sent" => Self::Sent {
395 at: get_str("at"),
396 permalink: get_str("permalink"),
397 },
398 "failed" => Self::Failed {
399 at: get_str("at"),
400 err: get_str("err"),
401 retries: as_u32(m.get(&dat!("retries"))),
402 },
403 other => Self::spent(&fmt!("a delivery state this version cannot read: '{}'", other)),
404 }
405 }
406
407 /// A failure that has used up its retries: a terminal state for a delivery nothing can safely act
408 /// on.
409 fn spent(why: &str) -> Self {
410 Self::Failed {
411 at: String::new(),
412 err: why.to_string(),
413 retries: MAX_RETRIES,
414 }
415 }
416}
417
418
419/// One post's delivery to one remote: where it goes, in what words, and how far it has got.
420#[derive(Clone, Debug, Eq, PartialEq)]
421pub struct Delivery {
422 pub dest: Destination,
423 pub rendition: Rendition,
424 pub state: DeliveryState,
425}
426
427impl Delivery {
428
429 pub fn new(dest: Destination, rendition: Rendition) -> Self {
430 Self { dest, rendition, state: DeliveryState::Queued }
431 }
432
433 pub fn to_dat(&self) -> Dat {
434 let mut m = DaticleMap::new();
435 m.insert(dat!("dest"), dat!(self.dest.as_str().to_string()));
436 m.insert(dat!("rendition"), self.rendition.to_dat());
437 m.insert(dat!("state"), self.state.to_dat());
438 Dat::Map(m)
439 }
440
441 /// The delivery from a daticle, or nothing where its destination is one this version does not know
442 /// -- a delivery the caller drops rather than misroutes.
443 pub fn from_dat(d: &Dat) -> Option<Self> {
444 let m = match d {
445 Dat::Map(m) => m,
446 _ => return None,
447 };
448 let dest = match m.get(&dat!("dest")) {
449 Some(Dat::Str(s)) => ok!(Destination::of(s)),
450 _ => return None,
451 };
452 let rendition = match m.get(&dat!("rendition")) {
453 Some(d) => Rendition::from_dat(d),
454 None => Rendition::default(),
455 };
456 let state = match m.get(&dat!("state")) {
457 Some(d) => DeliveryState::from_dat(d),
458 None => DeliveryState::Queued,
459 };
460 Some(Self { dest, rendition, state })
461 }
462}
463
464
465/// A `u32` out of a daticle that may hold one under any of the widths jdat writes an integer as, or
466/// zero where there is no readable number.
467fn as_u32(d: Option<&Dat>) -> u32 {
468 match d {
469 Some(Dat::U32(n)) => *n,
470 Some(Dat::U16(n)) => *n as u32,
471 Some(Dat::U8(n)) => *n as u32,
472 Some(Dat::U64(n)) => *n as u32,
473 _ => 0,
474 }
475}
476
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 /// Every destination's word round-trips, and a word from outside the set is nobody's destination.
483 #[test]
484 fn test_a_destination_round_trips_by_word_00() -> Outcome<()> {
485 for d in Destination::ALL {
486 assert_eq!(Destination::of(d.as_str()), Some(d));
487 }
488 assert_eq!(Destination::of("myspace"), None);
489 Ok(())
490 }
491
492 /// A capability's cost answers whether a link is in the body. X is the only costed one.
493 #[test]
494 fn test_x_costs_more_with_a_link_01() -> Outcome<()> {
495 let x = Destination::X.capability();
496 assert_eq!(x.cost_of(false), 15_000);
497 assert_eq!(x.cost_of(true), 200_000);
498 let m = Destination::Mastodon.capability();
499 assert_eq!(m.cost_of(true), 0);
500 Ok(())
501 }
502
503 /// A promo that fits is left whole; the link is always kept whole, and the title gives way.
504 #[test]
505 fn test_a_promo_keeps_the_link_whole_02() -> Outcome<()> {
506 let url = "https://example.com/asides/on-rent";
507 // Fits: title and link, untouched.
508 let r = Rendition::promo("On rent", url, Some(280));
509 assert!(r.text.contains("On rent"));
510 assert!(r.text.ends_with(url));
511 assert!(r.auto);
512 // Does not fit: the whole URL still survives, the title is cut.
513 let long = "On rent and the long slow theft of the thing a person stands on and calls their own";
514 let r = Rendition::promo(long, url, Some(60));
515 assert!(r.text.ends_with(url), "the link must survive whole: {:?}", r.text);
516 assert!(r.text.chars().count() <= 60, "over the limit: {:?}", r.text);
517 assert!(r.text.contains('…'), "a cut title should show it was cut: {:?}", r.text);
518 Ok(())
519 }
520
521 /// Where not even the link fits with a title, the link goes on its own rather than truncated.
522 #[test]
523 fn test_a_promo_sends_the_link_alone_when_it_must_03() -> Outcome<()> {
524 let url = "https://example.com/asides/on-rent";
525 let r = Rendition::promo("On rent", url, Some(url.chars().count() + 1));
526 assert_eq!(r.text, url);
527 Ok(())
528 }
529
530 /// The backoff doubles from the base and stops at the cap.
531 #[test]
532 fn test_backoff_doubles_then_caps_04() -> Outcome<()> {
533 assert_eq!(DeliveryState::backoff_secs(0), 60);
534 assert_eq!(DeliveryState::backoff_secs(1), 120);
535 assert_eq!(DeliveryState::backoff_secs(2), 240);
536 // Well past the cap, and no overflow panic at a silly retry count.
537 assert_eq!(DeliveryState::backoff_secs(6), 3600);
538 assert_eq!(DeliveryState::backoff_secs(1000), 3600);
539 Ok(())
540 }
541
542 /// The worker is done with a delivery once it is sent, or once it has failed past retrying.
543 #[test]
544 fn test_a_delivery_is_terminal_when_done_05() -> Outcome<()> {
545 assert!(!DeliveryState::Queued.is_terminal());
546 assert!(DeliveryState::Sent {
547 at: fmt!("t"), permalink: fmt!("p"),
548 }.is_terminal());
549 assert!(!DeliveryState::Failed {
550 at: fmt!("t"), err: fmt!("e"), retries: 1,
551 }.is_terminal());
552 assert!(DeliveryState::Failed {
553 at: fmt!("t"), err: fmt!("e"), retries: MAX_RETRIES,
554 }.is_terminal());
555 Ok(())
556 }
557
558 /// A delivery survives the trip through a daticle, in each of its states.
559 #[test]
560 fn test_a_delivery_round_trips_06() -> Outcome<()> {
561 let cases = [
562 DeliveryState::Queued,
563 DeliveryState::Sent { at: fmt!("2026-07-18T10:00:00Z"), permalink: fmt!("https://m/1") },
564 DeliveryState::Failed { at: fmt!("2026-07-18T10:00:00Z"), err: fmt!("429"), retries: 2 },
565 ];
566 for state in cases {
567 let d = Delivery {
568 dest: Destination::Mastodon,
569 rendition: Rendition { text: fmt!("On rent https://x"), auto: false },
570 state: state.clone(),
571 };
572 let back = match Delivery::from_dat(&d.to_dat()) {
573 Some(b) => b,
574 None => return Err(err!("a delivery did not round-trip: {:?}", d; Test, Missing)),
575 };
576 assert_eq!(back, d);
577 }
578 Ok(())
579 }
580
581 /// A delivery to a destination this version does not know is dropped, not misrouted or fatal.
582 #[test]
583 fn test_an_unknown_destination_is_dropped_07() -> Outcome<()> {
584 let mut m = DaticleMap::new();
585 m.insert(dat!("dest"), dat!("myspace"));
586 m.insert(dat!("rendition"), Rendition::default().to_dat());
587 m.insert(dat!("state"), DeliveryState::Queued.to_dat());
588 assert_eq!(Delivery::from_dat(&Dat::Map(m)), None);
589 Ok(())
590 }
591
592 /// A delivery state this version cannot read is a spent failure, not a queued re-send: a post whose
593 /// state is unreadable might already have gone, and must not be sent twice.
594 #[test]
595 fn test_an_unreadable_state_will_not_resend_08() -> Outcome<()> {
596 let mut m = DaticleMap::new();
597 m.insert(dat!("state"), dat!("halfway-out-the-door"));
598 let state = DeliveryState::from_dat(&Dat::Map(m));
599 assert!(state.is_terminal(), "an unreadable state must not be retried: {:?}", state);
600 match state {
601 DeliveryState::Failed { retries, .. } => assert_eq!(retries, MAX_RETRIES),
602 other => return Err(err!("expected a spent failure, got {:?}", other; Test, Mismatch)),
603 }
604 Ok(())
605 }
606}