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:
-
Availability and quota.
localStorageis unavailable or throwing in private-browsing modes, when storage is disabled, and when the ~5 MB per-origin quota is exceeded. A baresetItem/getItemthrows aDOMException; an app that doesn’t catch it crashes on the persistence path rather than degrading to “couldn’t save.” -
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 inrestore_statewith a corrupted-snapshot error (RFC-0012’sE701) — 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 torestore_state. -
Quiescence.
save_state()throwsE700if the system is not quiescent — an event is in flight (theE700contract 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. -
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) wrappingsave_state(), writes it to the configured store underkey, and returnstrueon success /falseif the store rejected the write (quota, unavailable). It MUST NOT swallowE700: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— readskeyfrom the store; if absent, returnsfalse. Otherwise it parses the envelope and checks the version and system identity (below); on a match it callsrestore_state(...)and returnstrue; on any mismatch or parse failure it returnsfalseand MUST NOT callrestore_state. Afalsereturn 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 whoseframedoesn’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 to1and is set with the companion attribute@@[persist_version(<int>)]. A value whosevdoesn’t match the current version MUST be ignored.blob— the exact output ofsave_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.
vis a manual integer, and only the root system carries@@[persist_version]. A domain change in a nested@@systemdoes 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>returningfalseon a stalevmeans 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 rawsave_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 → restorehook (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 methodsasync(Promise<bool>); whether that is astore:variant of this attribute or its own RFC is open. Large blobs (past thelocalStoragequota) motivate it.- wasm-hosted web targets. Reaching Web Storage from a wasm build needs a
JS-glue layer (e.g.
js-sys/wasm-bindgenfor Rust-wasm) rather than the directlocalStoragecalls 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 thelocalStoragequota 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
storageevent 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,E700quiescence,E701corrupted 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