Skip to content

feat(storage): add persistent state and storage backends - #722

Merged
stormmuller merged 5 commits into
devfrom
claude/design-persistent-preferences
Oct 6, 2026
Merged

stormmuller merged 5 commits into
devfrom
claude/design-persistent-preferences

Conversation

@stormmuller

Copy link
Copy Markdown
Member

Summary

Implements design/persistent-preferences.md (all of Phase 1) and deletes the design doc.

New module @forge-game-engine/forge/storage (src/storage, exported from src/index.ts and package.json):

  • StorageBackend: an asynchronous get/set/remove interface over strings. Its JSDoc and the guide state the contract: every promise settles, remove is idempotent, and creating a backend touches no storage.
  • createLocalStorageBackend(): does its work inside the call and returns an already-settled promise, so a write is stored before set returns. A missing localStorage becomes StorageUnavailableError, a SecurityError becomes StorageBlockedError, and QuotaExceededError (or Firefox's legacy NS_ERROR_DOM_QUOTA_REACHED) becomes StorageFullError, with the original error as cause. Any other error is rejected as it is. Errors are recognized by name, because DOMException isn't an Error instance in every environment.
  • createMemoryStorageBackend(): a per-instance Map.
  • createPersistentState(name, defaults, { validators, storage }):
    • Reads the stored entry once and validates each stored field: same type as its default, finite if it's a number, and passes its validator.
    • Stores only the keys that were set, and keeps stored fields this version doesn't know.
    • values is frozen and replaced on every change. onChange is raised after every set/reset. reset() removes the entry.
    • One write queue handles everything: at most one write is in flight. A change made while a write is in flight goes out with the next write, and its promise settles with that write. An idle queue starts its write synchronously.
    • A backend that throws instead of rejecting is treated the same as one that rejects.
  • Errors: PersistentStateFormatError when the entry isn't a JSON object, and PersistentStateValueError carrying field, value and stateName.

Docs and demo:

  • Guide: documentation-site/docs/docs/storage/ covers the index, persistent state (including writing a record into a component through onChange) and storage backends (including implementing your own).
  • Demo: persistent-state is in the ui category, since there's no storage category. It has a size slider, a spin toggle and a reset button, built from engine UI. The record is loaded before createGame, written into a demo component, and that component is read by a demo system.
  • Browser check: built with npm run build and checked in Chromium against the built docs site. The settings were stored ({"spin":false,"size":1.73…}), survived a reload, and Reset removed the entry and restored the defaults.

Changes from the design

  • No demo code to migrate. The "Galactic Journey" audio mixer and graphics settings store the design names aren't in this repo, so there's nothing to replace. The docs demo is the in-repo consumer. No audio-mixer code was added; that's design/audio-mixer.md's PR.
  • Defaults are validated too. createPersistentState rejects with PersistentStateValueError if a default fails its validator or is a non-finite number. This is a programming error, caught at creation time.
  • Unknown keys. set checks keys with Object.hasOwn(defaults, key), so a key with no default (including prototype names like constructor) throws PersistentStateValueError naming the field.
  • Name on every error. Every error class sets name, and the PersistentState* errors also carry stateName.
  • Nothing outdated. None of the later designs (entity handles, camera views, collision filtering, game states, HDR colors, the angle convention, font atlases, CCD) touch this design.

Pre-existing defect found (not fixed here)

createToggle shows the checkmark only from onValueChanged. Its JSDoc says isOn can be set directly, but a direct toggle.isOn = … write doesn't raise onValueChanged, so the checkmark doesn't update. The demo works around it by setting the checkmark sprite's enabled when the settings change, with a comment. The right fix is in /src/ui, and I left it out to keep this PR's scope.

Decisions taken from the design's open questions

  1. Renamed or retyped fields. Took (a): no migration hook. A renamed field takes its default, and a retyped one rejects with PersistentStateValueError. The demo handles that error by removing the entry and creating the record again.
  2. Should Forge write records into components for the game? Took (a): no helper. The guide and demo show the few lines of setup code (addComponent from values, then Object.assign on onChange).

Solution reviewer verdict

REVISE, with these points. I acted on all of them except the first:

  • Keep the design doc. The reviewer cited AGENTS.md, which says shipped designs stay as historical rationale. Not followed: the task explicitly asked for the doc to be deleted once fully implemented, which matches the repo's recent practice (docs(design): remove design documents that have been implemented #720). The links in audio-mixer.md and webgl-context-loss.md were reworded. demo-findings.md is left to the coordinator.
  • Drop the toggle guard. Dropped the planned "only set when the value differs" guard on the toggle listener. Direct value/isOn writes don't raise onValueChanged, so nothing loops back.
  • Use Object.hasOwn for keys. Done.
  • Set name on every error class. Done.
  • Say that invalid defaults reject. The JSDoc now says so, and it's tested.
  • Cover every §8 test case. All are covered: out-of-order settlement, reset during an in-flight write, a failed write followed by a successful one, a localStorage write landing before set returns, and importing the module without window (a node-environment test).

Related issue(s)

Part of #567 (save/load epic). It defines the StorageBackend that #567's save system will share.

Verification checklist

  • npm run check-types passes with 0 errors
  • npm test passes (1980 tests)
  • npm run lint passes with 0 errors (2 existing TODO warnings in material.ts)
  • npm run cspell passes with 0 errors
  • npm run check-exports passes
  • Any new/changed public API is exported from the module's index.ts
    (and /src/index.ts / package.json exports if it's a new module)
  • Documentation under /documentation-site/docs/docs is updated if this
    change affects documented behavior
  • If this change touches a module with a demo under
    /documentation-site/src/pages/demos, the demo has been updated and
    verified (see AGENTS.md's "Documentation Site Demos" section). Root npm run build, docs npm run typecheck and npm run build all ran, and the page was loaded in Chromium.

No e2e test: the module has no rendering, input or game-loop behaviour.

Changelog

  • A bullet has been added under ## [Unreleased] in CHANGELOG.md
    (required unless this PR's Conventional Commits type is chore,
    style, refactor, test, ci, docs, or build — see
    AGENTS.md's "Changelog" section)

🤖 Generated with Claude Code

https://claude.ai/code/session_01YXmwbd7e594EYzG7UrGdf8


Generated by Claude Code

Adds the @forge-game-engine/forge/storage module: the StorageBackend
interface with localStorage and memory backends, StorageError and its
subclasses, and createPersistentState, a typed record of flat values with
defaults, validation, an ordered write queue and an onChange event. Adds
a Storage guide and a Persistent State docs demo, and removes the
implemented design document.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YXmwbd7e594EYzG7UrGdf8
Comment thread src/storage/create-local-storage-backend.ts Outdated
Comment thread src/storage/create-local-storage-backend.ts Outdated
@stormmuller
stormmuller enabled auto-merge (squash) October 6, 2026 21:42
@stormmuller
stormmuller merged commit 0c57ca1 into dev Oct 6, 2026
12 checks passed
@stormmuller
stormmuller deleted the claude/design-persistent-preferences branch October 6, 2026 21:54
@codecov

codecov Bot commented Oct 6, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.51852% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/storage/persistent-state-errors.ts 85.71% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants