feat: add WebStorageStore for persistent (localStorage) caching#154
feat: add WebStorageStore for persistent (localStorage) caching#154FarajiOranj wants to merge 3 commits into
WebStorageStore for persistent (localStorage) caching#154Conversation
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 detectedLatest commit: 7e0fb20 The changes in this PR will be included in the next version bump. This PR includes changesets to release 5 packages
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.
|
Pushed a follow-up hardening commit after auditing the store for edge cases:
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.
|
A follow-up re-audit caught that my first serialization fix wasn't quite right: encoding bigints as a 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 |
Summary
Closes #84.
Adds a built-in persistent
Storeso a client's cache can survive page reloadsby persisting to
localStorage(or any other Web Storage-compatible source).drift's cache is already pluggable via the
Storeinterface (with an in-memoryLruStoredefault), and the README points users to implement their own storefor 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
No changes to
Client/createDriftwere needed, thestoreoption alreadyaccepts any
Storeinstance.WebStorageStoreStore, backed by the Web Storage API,localStoragebydefault or any compatible storage (
sessionStorage, polyfills) via thestorageoption.setpersists immediately, so there's no interval tomanage and no data-loss window.
prefix(default"drift:") soentries()andclear()only ever touch this store's own keys and never wipe unrelated datain the same storage.
Serialization
Web Storage only holds strings, and cached values contain
BigInts, so valuesgo through a serializer (overridable via the
serialize/deserializeoptions).The default codec (
serializeValue/deserializeValue) is designed to be a trueround-trip:
BigInts are encoded with a sentinel prefix that real cached data nevercollides with (and is escaped in the rare case it could), so neither a
look-alike string (e.g. a contract
stringreturn of"42n") nor anarbitrary object value is ever mistaken for a bigint, and malformed data can't
throw.
undefined) is treated as a delete, sohas/get/entriesstay consistent.setItemfailure (an exceeded quota orprivate-mode storage) is swallowed so a failed cache write can never fail an
otherwise-successful read, matching the in-memory
LruStorewhosesetneverthrows.
Tests
WebStorageStore.test.tscovers bigint round-trips, look-alike-stringpreservation, 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
ClientCachepath includingdeleteMatches-based invalidation. Also updated the README "Stores" section andadded a changeset.
Possible follow-ups
Happy to add these in separate PRs if useful:
TtlStorewrapper for time-based expiry (the linked Bancor inspiration useda TTL).
Storeinterface already supports async,so it can layer on top of this.