Skip to content

feat: add WebStorageStore for persistent (localStorage) caching#154

Open
FarajiOranj wants to merge 3 commits into
ryangoree:mainfrom
FarajiOranj:feat/persistent-cache
Open

feat: add WebStorageStore for persistent (localStorage) caching#154
FarajiOranj wants to merge 3 commits into
ryangoree:mainfrom
FarajiOranj:feat/persistent-cache

Conversation

@FarajiOranj

@FarajiOranj FarajiOranj commented Jul 8, 2026

Copy link
Copy Markdown

Summary

Closes #84.

Adds a built-in persistent Store so a client's cache can survive page reloads
by persisting to localStorage (or any other Web Storage-compatible source).

drift's cache is already pluggable via the Store interface (with an in-memory
LruStore default), and the README points users to implement their own store
for localStorage. This adds a ready-made one so they don't have to, handling the
tricky parts (BigInt serialization and namespacing) out of the box.

Usage

import { createDrift, WebStorageStore } from "@gud/drift";

const drift = createDrift({
  adapter,
  // Persist the cache to localStorage across page reloads.
  store: new WebStorageStore(),
});

No changes to Client/createDrift were needed, the store option already
accepts any Store instance.

WebStorageStore

  • Implements Store, backed by the Web Storage API, localStorage by
    default or any compatible storage (sessionStorage, polyfills) via the
    storage option.
  • Write-through: every set persists immediately, so there's no interval to
    manage and no data-loss window.
  • Namespaced with a prefix (default "drift:") so entries() and
    clear() only ever touch this store's own keys and never wipe unrelated data
    in the same storage.

Serialization

Web Storage only holds strings, and cached values contain BigInts, so values
go through a serializer (overridable via the serialize/deserialize options).
The default codec (serializeValue/deserializeValue) is designed to be a true
round-trip:

  • BigInts are encoded with a sentinel prefix that real cached data never
    collides with (and is escaped in the rare case it could), so neither a
    look-alike string (e.g. a contract string return of "42n") nor an
    arbitrary object value is ever mistaken for a bigint, and malformed data can't
    throw.
  • A value with no JSON form (e.g. undefined) is treated as a delete, so
    has/get/entries stay consistent.
  • Writes are best-effort: a setItem failure (an exceeded quota or
    private-mode storage) is swallowed so a failed cache write can never fail an
    otherwise-successful read, matching the in-memory LruStore whose set never
    throws.

Tests

WebStorageStore.test.ts covers bigint round-trips, look-alike-string
preservation, unserializable-value handling, best-effort writes on a failing
storage, prefix isolation, persistence across store instances (the reload
scenario), a custom serializer, and the full ClientCache path including
deleteMatches-based invalidation. Also updated the README "Stores" section and
added a changeset.

Possible follow-ups

Happy to add these in separate PRs if useful:

  • A TtlStore wrapper for time-based expiry (the linked Bancor inspiration used
    a TTL).
  • An async/IndexedDB-backed store; the Store interface already supports async,
    so it can layer on top of this.

Adds a built-in `Store` implementation backed by the Web Storage API
(`localStorage` by default, or any compatible storage such as
`sessionStorage`), so a client's cache can survive page reloads.

Pass it as the `store` option:

    const drift = createDrift({ store: new WebStorageStore() });

Values are serialized with a BigInt-safe JSON serializer by default
(overridable), and every entry is namespaced with a configurable `prefix`
so `entries()`/`clear()` only touch this store's own keys. No changes to
`Client`/`createDrift` were needed since the `store` option already accepts
any `Store`.
@changeset-bot

changeset-bot Bot commented Jul 8, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 7e0fb20

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 5 packages
Name Type
@gud/drift Minor
@gud/drift-ethers-v5 Minor
@gud/drift-ethers Minor
@gud/drift-viem Minor
@gud/drift-web3 Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Address issues found while auditing the persistent store:

- Use a dedicated BigInt-safe value codec (`serializeValue`/`deserializeValue`)
  instead of the key codec. The key codec encoded bigints as "<n>n" strings,
  which silently coerced a real string value like "42n" into a BigInt on read.
  BigInts are now encoded as a tagged wrapper object that round-trips
  unambiguously.
- `set` now treats a value with no JSON form (e.g. `undefined`) as a delete,
  so `has`/`get`/`entries` stay consistent instead of storing the literal
  string "undefined" and later throwing on parse.
- `set` is now best-effort: a storage write failure (e.g. an exceeded quota or
  private-mode storage) is swallowed so a failed cache write can't fail a
  successful read.

Also made the test storage double coerce values to strings like real Web
Storage, and added regression tests for each case.
@FarajiOranj

Copy link
Copy Markdown
Author

Pushed a follow-up hardening commit after auditing the store for edge cases:

  • Value codec: the default serializer previously reused drift's key codec (bigint → "<n>n"), which would silently coerce a real cached string like "42n" (e.g. a contract string return) into a BigInt on reload. Bigints are now encoded as an unambiguous tagged wrapper via new serializeValue/deserializeValue helpers, so look-alike strings round-trip intact.
  • undefined/unserializable values: set now treats a value with no JSON form as a delete, keeping has/get/entries consistent instead of persisting the literal string "undefined" and later throwing on parse.
  • Quota / best-effort writes: a setItem failure (exceeded quota, private-mode storage) is now swallowed so a failed cache write can't fail an otherwise-successful read (matching the in-memory LruStore, whose set never throws).

Added regression tests for each and made the test storage double coerce to strings like real Web Storage.

The previous fix encoded bigints as a `{ "$drift$bigint": "<n>" }` wrapper
object, which a re-audit showed could collide with a real cached object of
that exact shape: it was silently coerced to a bigint, or (if the value
wasn't a valid integer) `BigInt()` threw, propagating out of `get`/`entries`
and breaking `deleteMatches` for the whole store.

Encode bigints as a sentinel-prefixed string instead (a leading NUL, which
real cached strings never contain and which is escaped in the rare case they
do). Objects are never wrapped, so no object shape can be mistaken for an
encoded bigint, and the conversion is guarded so malformed data can never
throw. Added regression tests for the object-shape and look-alike cases.
@FarajiOranj

Copy link
Copy Markdown
Author

A follow-up re-audit caught that my first serialization fix wasn't quite right: encoding bigints as a { "$drift$bigint": ... } wrapper object could collide with a real cached object of that exact shape (silent coercion, or a BigInt() throw that broke entries()/deleteMatches).

Reworked the codec to encode bigints as a sentinel-prefixed string instead of a wrapper object, so no object shape can ever be mistaken for a bigint, look-alike strings like "42n" stay strings, and malformed data is guarded so it can't throw. Verified with a 16-case round-trip repro and new regression tests; a fresh adversarial pass over the new codec came back clean.

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.

Add ability to persist cache in localStorage (or other source)

1 participant