Skip to main content

logicaffeine_compile/semantics/
storage.rs

1//! Browser storage — the AREAS a page can keep text in, and the one place every tier decides what
2//! an area IS.
3//!
4//! A page that says it persists something must actually persist it. Before this, the only
5//! storage anywhere was a map inside the running program: a `## Store … persisted as "key"`
6//! compiled to WebAssembly kept its state in the module's own memory, which dies with the page.
7//! The sentence said "persisted" and a reload started from the initializers.
8//!
9//! ★ TEXT IN, TEXT OUT, AND NOTHING IS SPECIAL. A stored value is an opaque string: the browser
10//! API is already length-delimited, so there is no separator to escape and no byte that means
11//! anything. A quote, a newline, a NUL and a `☃` are ordinary content. This is the same reason
12//! the patch stream length-prefixes its text rather than delimiting it — a format with no
13//! delimiter has no escaping bug class.
14//!
15//! ★ A MISSING KEY IS `None`, NEVER `""`. An empty string is a value a page legitimately stores
16//! (an empty draft is exactly what a cleared form field holds), and a reader that cannot tell
17//! "nothing is saved" from "an empty string is saved" will overwrite one with the other.
18
19use std::cell::RefCell;
20use std::collections::BTreeMap;
21
22/// Survives closing the tab — what `persisted as "key"` means.
23pub const LOCAL: &str = "local";
24/// Survives a RELOAD and ends with the tab — what `preserved across reloads` means.
25pub const SESSION: &str = "session";
26/// The topics every page on them shares — what `## Store … shared on "topic"` keeps its fields in,
27/// keyed `<topic>.<field>`. A write is a frame the other pages on the topic receive
28/// ([`take_shared_writes`]); the page that receives one merges it (`__lgx_shared`), the host never
29/// does. Survives a reload and closing the tab: it lives with the topic, not the page.
30pub const SHARED: &str = "shared";
31
32#[derive(Default)]
33struct Areas {
34    local: BTreeMap<String, String>,
35    session: BTreeMap<String, String>,
36    shared: BTreeMap<String, String>,
37    /// Every write to [`SHARED`] no other page has been handed yet, `(key, frame)`, in order.
38    unsent: Vec<(String, String)>,
39}
40
41impl Areas {
42    fn area(&mut self, name: &str) -> &mut BTreeMap<String, String> {
43        match name {
44            LOCAL => &mut self.local,
45            SESSION => &mut self.session,
46            _ => &mut self.shared,
47        }
48    }
49}
50
51thread_local! {
52    static AREAS: RefCell<Areas> = RefCell::new(Areas::default());
53}
54
55/// Check an area name, or say what the areas are.
56///
57/// Not a silent no-op and not a third area invented on the spot: a page written against an area
58/// nobody defines would store its state where nothing ever reads it, and would look like it
59/// worked right up until the reload it was written for.
60pub fn check_area(area: &str) -> Result<(), String> {
61    if area == LOCAL || area == SESSION || area == SHARED {
62        return Ok(());
63    }
64    Err(format!(
65        "I do not know a storage area called \"{area}\". A page can store text in \"{LOCAL}\", \
66         which survives closing the tab, \"{SESSION}\", which survives a reload and ends with the \
67         tab, or \"{SHARED}\", which every page on a topic shares. Which did you mean?"
68    ))
69}
70
71pub fn set(area: &str, key: &str, value: &str) -> Result<(), String> {
72    check_area(area)?;
73    AREAS.with(|a| {
74        let mut a = a.borrow_mut();
75        a.area(area).insert(key.to_string(), value.to_string());
76        if area == SHARED {
77            a.unsent.push((key.to_string(), value.to_string()));
78        }
79    });
80    Ok(())
81}
82
83pub fn get(area: &str, key: &str) -> Result<Option<String>, String> {
84    check_area(area)?;
85    Ok(AREAS.with(|a| a.borrow_mut().area(area).get(key).cloned()))
86}
87
88/// Every write to [`SHARED`] since the last look, `(topic, key, frame)`, in order — taken, so the
89/// host hands each to the other pages on its topic once, as a relay delivers a published frame.
90/// The topic is the key's part before its first `.`.
91pub fn take_shared_writes() -> Vec<(String, String, String)> {
92    AREAS
93        .with(|a| std::mem::take(&mut a.borrow_mut().unsent))
94        .into_iter()
95        .map(|(key, frame)| (key.split('.').next().unwrap_or("").to_string(), key, frame))
96        .collect()
97}
98
99/// Removing a key that was never there is not an error: a page clearing state it may or may not
100/// have written must not have to ask first.
101pub fn remove(area: &str, key: &str) -> Result<(), String> {
102    check_area(area)?;
103    AREAS.with(|a| {
104        a.borrow_mut().area(area).remove(key);
105    });
106    Ok(())
107}
108
109/// A FRESH BROWSER PROFILE — every area empty.
110///
111/// This is what starting a program means in a sealed world: an AOT-compiled binary and a browser
112/// opened for the first time both begin with nothing stored. A run that inherited another run's
113/// storage would make every test depend on the order the tests happened to run in.
114pub fn reset() {
115    AREAS.with(|a| *a.borrow_mut() = Areas::default());
116}
117
118/// Every area, saved. What [`take`] returns and [`restore`] puts back.
119#[derive(Default)]
120pub struct StorageState(Areas);
121
122/// Take the current storage, leaving a fresh (empty) profile in its place — [`reset`], but the
123/// profile it replaces comes back to the caller instead of being dropped. A nested run (a candidate
124/// page tier compared against the reference) takes the caller's storage before writing its own, and
125/// [`restore`]s it when it is done, so the caller's own storage survives the comparison instead of
126/// leaking into — or out of — the candidate's run.
127pub fn take() -> StorageState {
128    StorageState(AREAS.with(RefCell::take))
129}
130
131/// Put back a storage profile a matching [`take`] returned.
132pub fn restore(state: StorageState) {
133    AREAS.with(|a| *a.borrow_mut() = state.0);
134}
135
136/// CLOSING THE TAB — the session area ends; the local and shared areas survive.
137///
138/// This is the whole difference between the two areas, and the only operation that can tell them
139/// apart from the outside. Without it "preserved across reloads" and "persisted" would be two
140/// spellings of the same lifetime.
141pub fn end_session() {
142    AREAS.with(|a| a.borrow_mut().session.clear());
143}
144
145/// Every key an area holds, in sorted order — for a test or a dev panel that wants to see what a
146/// page has left on a visitor's machine.
147pub fn keys(area: &str) -> Result<Vec<String>, String> {
148    check_area(area)?;
149    Ok(AREAS.with(|a| a.borrow_mut().area(area).keys().cloned().collect()))
150}