Release 0.5.1
This commit is contained in:
@@ -4,6 +4,10 @@ One line per change, grouped by the release that shipped it. Newest
|
||||
first. A release is a Cargo.toml bump plus a `v<version>` tag; the
|
||||
content repos pin the tag they run (see the end of this file).
|
||||
|
||||
## 0.5.1 (2026-09-28)
|
||||
|
||||
- A `self_transition` that names no `from` means the bucket's own initial state, not the word `open`. 0.5.0 read the literal, which is only right for a bucket whose records start there: redoal's watchers arrive at `pending` and confirm themselves into `open`, so the rule compiled as `open -> open` and the confirm link in the mail could never match. Wrong the same way before 0.5.0, and now visible because the lint checks the edge exists.
|
||||
|
||||
## 0.5.0 (2026-09-28)
|
||||
|
||||
- Sessions live in NATS KV (`src/sessions.rs`), not in memory. Every restart used to sign out every desk on the site being deployed; they now survive it, in the JetStream that is already under everything else - no new service and no new store to run. The bucket's `max_age` collects what nobody returns for, and a record's own expiry is what answers a load.
|
||||
|
||||
Generated
+1
-1
@@ -3357,7 +3357,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "portal"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "portal"
|
||||
version = "0.5.0"
|
||||
version = "0.5.1"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
+37
-2
@@ -223,8 +223,15 @@ impl Policy {
|
||||
if let Some(st) = &alt.self_transition {
|
||||
// One rule per state the move may be made from: a
|
||||
// person stays in dialogue with their case for as
|
||||
// long as it runs, and `from` says how long that is.
|
||||
for from in &st.from {
|
||||
// long as it runs, and `from` says how long that
|
||||
// is. Saying nothing means the bucket's own
|
||||
// initial state - the bucket's, not the word
|
||||
// `open`, since a bucket may start anywhere.
|
||||
let initial = aggregates
|
||||
.get(&st.bucket)
|
||||
.map(|s| s.initial.clone())
|
||||
.unwrap_or_else(|| crate::answers::OPEN_STATE.to_string());
|
||||
for from in &st.from_states(&initial) {
|
||||
emit(
|
||||
format!("self:{}:{from}>{}", st.bucket, st.to), "the submitter, by their link and email".to_string(), "self_transition",
|
||||
format!("bucket {}", st.bucket), format!("{from} -> {}", st.to),
|
||||
@@ -577,6 +584,34 @@ mod tests {
|
||||
assert_eq!(p.rules.iter().filter(|r| r.id.starts_with("self:subscribers:")).count(), 1);
|
||||
}
|
||||
|
||||
// "On arrival" is the bucket's own first state, not the word
|
||||
// `open`. redoal's watchers arrive at `pending` and confirm
|
||||
// themselves into `open`; reading the literal made that rule
|
||||
// `open -> open`, which the engine declares nowhere, so the
|
||||
// confirm link in the mail could never have worked.
|
||||
#[test]
|
||||
fn arrival_is_wherever_the_bucket_actually_starts() {
|
||||
let files = vec![(
|
||||
"index.yaml".to_string(),
|
||||
"name: Home\nalternatives:\n - name: Ask\n record_as: watchers\n - name: Yes, watch it\n self_transition: { bucket: watchers, to: open, label: Yes }\n".to_string(),
|
||||
)];
|
||||
let questions = build_questions(&files).unwrap();
|
||||
let aggregates = parse_aggregates_yaml(
|
||||
"aggregates:\n - bucket: watchers\n initial: pending\n states:\n pending: { event: requested }\n open: { event: confirmed }\n transitions:\n pending: [open]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let p = Policy::from_content(&questions, &aggregates);
|
||||
assert_eq!(p.validate(), Ok(()), "{}", p.text);
|
||||
assert!(
|
||||
p.rules.iter().any(|r| r.id == "self:watchers:pending>open"),
|
||||
"the rule must start where the records do: {:?}",
|
||||
p.rules.iter().map(|r| &r.id).collect::<Vec<_>>()
|
||||
);
|
||||
let me = Principal::Submitter { record: "r".into(), email: "a@b.c".into() };
|
||||
let bucket = Resource::Bucket { name: "watchers".into() };
|
||||
assert!(p.decide(&me, Action::SelfTransition, &bucket, json!({ "from": "pending", "to": "open", "stored_email": "a@b.c" })).allow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invites_and_automation() {
|
||||
let p = site();
|
||||
|
||||
+7
-1
@@ -376,7 +376,13 @@ pub async fn self_transition_answer(
|
||||
// and the answer must not confirm that the record exists. If it
|
||||
// is not, the case has moved past this move and saying so is no
|
||||
// longer a way to learn anything about it.
|
||||
let in_reach = self_transition.from.iter().any(|s| s == &answer.state);
|
||||
let initial = state
|
||||
.aggregates
|
||||
.load()
|
||||
.get(&self_transition.bucket)
|
||||
.map(|s| s.initial.clone())
|
||||
.unwrap_or_else(|| OPEN_STATE.to_string());
|
||||
let in_reach = self_transition.from_states(&initial).iter().any(|s| s == &answer.state);
|
||||
return Err(ServerFnError::new(if in_reach { "not found" } else { "already processed" }));
|
||||
}
|
||||
|
||||
|
||||
+20
-6
@@ -348,15 +348,29 @@ pub struct SelfTransition {
|
||||
/// with a case for as long as it runs, not only on the day they
|
||||
/// sent it: an applicant written to for a missing drawing answers
|
||||
/// from `incomplete`, and may withdraw from anywhere along the
|
||||
/// way. Defaults to the initial state alone, which is what every
|
||||
/// one-shot "unsubscribe" link wants and what this was before it
|
||||
/// could be said.
|
||||
#[serde(default = "initial_state_only")]
|
||||
/// way.
|
||||
///
|
||||
/// Empty means the bucket's own initial state, which is the
|
||||
/// one-shot "confirm" or "unsubscribe" link every one of these was
|
||||
/// before it could say otherwise. Its own initial state, not the
|
||||
/// word `open`: a bucket whose records start at `pending` has a
|
||||
/// confirmation link that fires from `pending`, and reading the
|
||||
/// literal here is what made redoal's watch-confirm button a rule
|
||||
/// the engine would never match.
|
||||
#[serde(default)]
|
||||
pub from: Vec<String>,
|
||||
}
|
||||
|
||||
fn initial_state_only() -> Vec<String> {
|
||||
vec![crate::answers::OPEN_STATE.to_string()]
|
||||
impl SelfTransition {
|
||||
/// The states this move may be made from, with the bucket's
|
||||
/// initial state standing in when the content named none.
|
||||
pub fn from_states(&self, initial: &str) -> Vec<String> {
|
||||
if self.from.is_empty() {
|
||||
vec![initial.to_string()]
|
||||
} else {
|
||||
self.from.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -242,13 +242,7 @@ pub fn validate_questions(
|
||||
question.id, alternative.name, st.to, st.bucket
|
||||
);
|
||||
}
|
||||
if st.from.is_empty() {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: self_transition.from is empty, so the move can never be made",
|
||||
question.id, alternative.name
|
||||
);
|
||||
}
|
||||
for from in &st.from {
|
||||
for from in &st.from_states(&schema.initial) {
|
||||
if !schema.has_state(from) {
|
||||
anyhow::bail!(
|
||||
"question {:?} alternative {:?}: self_transition.from {:?} is not a declared state for bucket {:?}",
|
||||
|
||||
Reference in New Issue
Block a user