RFC-0041 — Web persistence: storage-bound save/load for browser targets

  • Status: Draft
  • Author: Mark Truluck mark@frame-lang.org
  • Created: 2026-05-26
  • Builds on: RFC-0012 (the persist contract), RFC-0015 (factory / save / load), RFC-0016 (selective-domain persist)

The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119.

Summary

The persist contract already gives a web target everything needed to serialize a system: on JavaScript and TypeScript, save returns a JSON string and load takes one back. What it does not give is a defined, correct way to put that string somewhere durable in a browser. This RFC adds one opt-in system-header attribute, @@[web_persist(<save_to>, <load_from>)], that generates a storage-bound save/load pair on top of the existing save_state / restore_state — wrapping the blob in a small versioned envelope and writing it to the Web Storage API (localStorage by default). The blob itself is unchanged; this is purely the binding from blob to browser storage, with the sharp edges (availability, quota, schema drift) handled once in generated code instead of by hand in every Frame web app. Scope for the first cut is JavaScript and TypeScript only; asynchronous and wasm-hosted stores are deferred (see Unresolved questions).

Motivation

A Frame system compiled to JavaScript and run in a browser commonly wants its state to survive a page reload: a game’s progress, a multi-step form’s answers, an editor’s session. Today the author writes that glue by hand, and it is the same glue every time:

// on change / on a timer:
localStorage.setItem("appState", machine.save_state());
// on boot:
const saved = localStorage.getItem("appState");
if (saved !== null) machine.restore_state(saved);

Four things make that deceptively hard to get right, and every project rediscovers them:

  1. Availability and quota. localStorage is unavailable or throwing in private-browsing modes, when storage is disabled, and when the ~5 MB per-origin quota is exceeded. A bare setItem / getItem throws a DOMException; an app that doesn’t catch it crashes on the persistence path rather than degrading to “couldn’t save.”

  2. Schema drift across deploys. A new build changes a system’s domain shape. A blob written by the old build may still JSON.parse, then fail in restore_state with a corrupted-snapshot error (RFC-0012’s E701) — or, worse on a lenient field, restore wrong. The blob needs a version stamp and an identity stamp so a stale or foreign blob is recognized and ignored, not fed to restore_state.

  3. Quiescence. save_state() throws E700 if the system is not quiescent — an event is in flight (the E700 contract is in RFC-0012). Naïve “autosave on every state change” wired into a handler violates that. The persistence call site has to be outside the handler.

  4. No standard, so no reuse. Because each app writes its own glue, none of the above is shared, reviewed, or tested in one place — and the GDScript side of the same model often does persist (it has the file-backed save/load from RFC-0015), so the web build silently lacks feature parity with its sibling.

Frame already produces the blob. The missing primitive is a correct, opt-in, generated binding from that blob to browser storage. That is what this RFC specifies.

The contract

The attribute

A system MAY declare:

@@[web_persist(<save_to_name>, <load_from_name>)]

It names two host-language methods framec generates on the system class — mirroring the way @@[save] and @@[load] name the blob-level pair. <save_to_name> persists to storage; <load_from_name> rehydrates from storage.

A system carrying @@[web_persist] MUST also carry @@[persist(string)] with its @@[save] / @@[load] names; web persistence is a binding over the string blob, not a replacement for it. The attribute is valid only on the javascript and typescript targets (see Unresolved questions for wasm and asynchronous stores).

Generated behavior

Let the existing pair be save_state() / restore_state(blob). @@[web_persist] generates, on the web targets:

  • <save_to_name>(key?: string): bool — builds a persist envelope (below) wrapping save_state(), writes it to the configured store under key, and returns true on success / false if the store rejected the write (quota, unavailable). It MUST NOT swallow E700: save_state() is evaluated before the guarded write, so a non-quiescent call still throws and the bug stays visible.

  • <load_from_name>(key?: string): bool — reads key from the store; if absent, returns false. Otherwise it parses the envelope and checks the version and system identity (below); on a match it calls restore_state(...) and returns true; on any mismatch or parse failure it returns false and MUST NOT call restore_state. A false return means “no usable save — proceed fresh,” never an exception.

key defaults to a stable per-system key — see Key derivation below — and an explicit argument lets one app key several instances (e.g. per save-slot).

Key derivation

The default key is "frame:" + <SystemName>. This is convenient but is not unique across distinct apps served from the same origin: two unrelated Frame apps that each declare a system named Game would map to the same default key, and the envelope’s frame field (the system name) would not tell them apart. Therefore:

  • An author deploying more than one Frame app to a single origin SHOULD pass an explicit key (or per-app prefix) rather than rely on the default.
  • A future refinement MAY fold a build- or app-level discriminator into the default key; that discriminator does not exist in the language today, so it is left to Unresolved questions rather than invented here.

The persist envelope

The stored value is not the bare blob. It is a small JSON envelope so that a stale or foreign value is detected before it ever reaches restore_state:

{ "frame": "<SystemName>", "v": <schema_version>, "blob": <save_state output> }
  • frame — the system’s name. A value whose frame doesn’t match is from a different system and MUST be ignored by <load_from_name>. (Note the same-name-different-app limitation under Key derivation.)
  • v — the schema version, an integer the author bumps when a domain change makes old blobs unreadable. It defaults to 1 and is set with the companion attribute @@[persist_version(<int>)]. A value whose v doesn’t match the current version MUST be ignored.
  • blob — the exact output of save_state(), untouched.

This makes “a new deploy invalidates incompatible saves” a one-line change (bump @@[persist_version]) with a clean fallback (<load_from_name> returns false, the app starts fresh) rather than an exception or a silent mis-restore.

Two honest limits of this scheme, both deferred rather than solved here:

  • Versioning is author-managed and root-only. v is a manual integer, and only the root system carries @@[persist_version]. A domain change in a nested @@system does not force a bump — the author must remember. A structural fingerprint derived from the domain shape would be safer than a hand-bumped integer; see Unresolved questions.
  • A version mismatch discards the old save. <load_from_name> returning false on a stale v means the prior state is dropped and the app starts fresh — acceptable for some apps, data loss for others. There is no built-in read-old → transform → restore migration path; the raw save_state() / restore_state() pair remains the escape hatch for authors who need one. A generated migration hook is Unresolved.

Storage backend

The default store is localStorage (synchronous, per-origin, persists across sessions). A system MAY select sessionStorage (per-tab, cleared on close) with the companion attribute @@[web_store("session")]; @@[web_store("local")] is the default. Both are synchronous, so the generated methods are synchronous and return bool. (For a large composed model the synchronous save_state() + JSON + setItem runs on the main thread; authors of large models should save on an idle/timer boundary, not in a hot path.)

IndexedDB (asynchronous, large-capacity) is intentionally out of scope here — binding it generates async methods returning Promise<bool> and is a distinct enough shape to be its own proposal; see Alternatives and Unresolved questions. The escape hatch for large or async storage today is the raw save_state() / restore_state() pair, which @@[web_persist] does not remove.

Composition

@@[web_persist] adds no new serialization. It persists exactly what save_state() already produces, which — per RFC-0015 — includes every nested @@system domain field, recursively. A composed root that is @@[web_persist] therefore round-trips its whole owned tree to storage in one call. Owned sub-systems need @@[persist] (so the root’s save_state can serialize them) but do not need their own @@[web_persist] — only the root the host actually saves does.

Fields excluded with @@[no_persist] (RFC-0016) stay excluded from the web envelope too; the envelope wraps the same blob.

Validator rules

These take fresh codes above the current persist/attribute range (the E82x block was already occupied by the import and @@codegen removals):

Code Trigger
E825 @@[web_persist] on a system without @@[persist] / @@[save] / @@[load]
E826 @@[web_persist] (or @@[web_store], @@[persist_version]) on a non-web target
E827 @@[web_store(<x>)] where <x> is not "local" or "session"
E817 a <save_to_name> / <load_from_name> that is not a valid identifier in the target language (reuses RFC-0015’s existing identifier-name rule, as for @@[save] / @@[load] names)

E700 (non-quiescent save) and E701 (corrupted snapshot) are inherited unchanged from the persist contract; <load_from_name> converts the expected “no/old save” cases into a false return so E701 is reserved for a blob that passed the envelope checks yet still failed to deserialize.

Examples

A browser game that survives reloads. The root system composes its sub-systems; only the root carries @@[web_persist]:

@@[persist(string)]
@@[save(save_state)]
@@[load(restore_state)]
@@[web_persist(save_to_browser, load_from_browser)]
@@[persist_version(3)]
@@[main]
@@system Game {
    interface:
        tick()
    machine:
        $Playing {
            tick() { self.turns = self.turns + 1 }
        }
    domain:
        turns: int = 0
        world = @@World()        // also @@[persist]; serialized recursively
}

Host wiring becomes two calls with correct fallback built in:

const game = Game();                 // factory (RFC-0017)
if (!game.load_from_browser()) {
    // no usable save (absent, wrong version, or unparseable) — fresh start
}

// ... later, between turns (quiescent), e.g. on an autosave timer:
game.save_to_browser();              // false if storage is full/unavailable

Per-slot keys reuse the same generated method:

game.save_to_browser("slot-2");
game.load_from_browser("slot-2");

Session-scoped state (a wizard that should reset when the tab closes):

@@[persist(string)]
@@[save(save_state)]
@@[load(restore_state)]
@@[web_persist(stash, unstash)]
@@[web_store("session")]
@@[main]
@@system Wizard { /* ... */ }

Alternatives

Status quo — hand-written glue. Rejected as the default: it is the source of the four problems in Motivation, and it leaves web builds without the durability their GDScript/desktop siblings already have. The raw save_state() / restore_state() pair remains available for anyone who wants full manual control, so nothing is taken away.

A runtime npm/ESM helper library instead of codegen. Rejected: it introduces a versioned dependency and the attendant skew between the generated code and the helper. Frame’s value is self-contained, deterministic generated output; emitting the small, fixed wrapper inline keeps that property.

Fold web storage into @@[persist] via a store: argument. Rejected: @@[persist] defines the blob contract and is deliberately target-agnostic (the same attribute, with a per-target blob type, is what makes a model portable across GDScript, Rust, Python, …). Web storage is a web-only binding. Keeping it in a separate, target-gated attribute preserves that separation of concerns — @@[persist] says “this is serializable”; @@[web_persist] says “and on the web, here’s where it lives.”

Put the version/identity envelope in the core persist contract, not here. Genuinely tempting, and the strongest alternative: schema drift and stale-blob detection are a cross-target problem — the GDScript/desktop file-backed save has the same footgun — so a version+identity stamp arguably belongs in @@[persist] (RFC-0012), with @@[web_persist] merely consuming it. This RFC deliberately keeps the envelope web-local for a first, shippable cut, because the web binding is where the need is sharpest and the wrapper is small. Promoting @@[persist_version] and the identity stamp to the core contract — so every target gets drift protection uniformly — is recorded as the leading follow-on in Unresolved questions, not foreclosed.

A user-implemented storage-adapter interface. Considered, and retained as the escape hatch rather than the primary path: a model that needs IndexedDB, a server round-trip, or cross-tab sync uses save_state() / restore_state() directly against its own adapter. @@[web_persist] is the batteries-included answer for the overwhelmingly common case (synchronous localStorage); it does not try to be the answer for every backend.

Built-in autosave (persist after every event). Rejected for this RFC: correct autosave has to respect quiescence (E700) and has performance and write-amplification implications that belong to the application, not the language. A future @@[autosave] companion that persists after an interface call returns (and only when quiescent) is a plausible follow-on; it is noted in Unresolved questions, not specified here.

Unresolved questions

  • Promote version/identity stamping to the core persist contract. The leading follow-on: lift @@[persist_version] and the envelope’s identity stamp out of this web-only RFC into RFC-0012 so GDScript, Rust, Python, and every other persisting target get the same drift protection, with @@[web_persist] consuming a shared mechanism. Deferred only to keep this first cut small.
  • Old-blob migration. A version mismatch currently discards the prior save. A generated read-old → transform → restore hook (or a documented pattern over the raw pair) would let apps upgrade saves instead of dropping them.
  • Auto-derived schema version. A structural fingerprint of the (recursive) domain shape would catch drift the author forgot to hand-bump — including drift in a nested system that the root-only @@[persist_version] misses today.
  • IndexedDB / async stores. Binding an asynchronous store makes the generated methods async (Promise<bool>); whether that is a store: variant of this attribute or its own RFC is open. Large blobs (past the localStorage quota) motivate it.
  • wasm-hosted web targets. Reaching Web Storage from a wasm build needs a JS-glue layer (e.g. js-sys / wasm-bindgen for Rust-wasm) rather than the direct localStorage calls the js/ts emitters produce; scoping that is left to a follow-up once the js/ts form lands.
  • Default-key disambiguation. Whether to fold a build/app discriminator into the default key (see Key derivation) once such an identifier exists in the language.
  • Compression. A save_state() blob can approach the localStorage quota for large composed models; an optional compression step in the envelope (and a flag on it) is a possible refinement.
  • Cross-tab coherence. Reacting to the storage event so two tabs of the same app converge is application behavior today; a generated hook is a possible future.
  • Integrity / confidentiality. The envelope is plaintext JSON, same-origin only; it is explicitly not a security boundary and MUST NOT hold secrets. Signing/encryption is out of scope.

Migration

Source-additive; no breaking change. Existing @@[persist] systems are unaffected and keep their blob-level save_state / restore_state. @@[web_persist], @@[web_store], and @@[persist_version] are new opt-in attributes; a system adopts web persistence by adding them, and nothing else changes.

References

  • RFC-0012 — the persist contract (save_state / restore_state, E700 quiescence, E701 corrupted snapshot, the JSON blob on JS/TS)
  • RFC-0015 — factory-only construction; @@[persist] / @@[save] / @@[load]; recursive serialization of composed systems
  • RFC-0016 — selective-domain persist; @@[no_persist]
  • RFC-0017 — the per-backend factory / no-initialization call shapes
  • Frame language reference@@[persist], attributes
  • Glossary — persist contract, save, load, domain
  • CHANGELOG.md