storage global. Values are persisted to the database, so they survive restarts and deploys and are readable from every runtime server immediately — there’s no per-server cache to warm and no worker to run.
Every key is automatically scoped to the current brand — scripts can never read or write another brand’s data. Values are JSON-serialised, so you can store strings, numbers, booleans, arrays, and plain objects.
Storage vs. Cache
Both are brand-scoped key-value APIs, but they solve different problems:Options
Every method takes an optional trailingopts object:
storage.get(key, opts?)
Retrieve a value.
null if the key is missing or expired.
storage.set(key, value, opts?)
Store a value, overwriting any existing one.
Returns:
true on success, false if the brand key limit was reached.
storage.del(key, opts?)
Delete a key.
true.
storage.has(key, opts?)
Check whether a live (non-expired) key exists.
true if the key exists and has not expired, false otherwise.
storage.setIfAbsent(key, value, opts?)
Write a value only if the key is absent or expired. This is atomic (row-locked), which makes it a reliable short-lived mutex across all runtime servers.
true if the value was written, false if a live value already existed.
storage.compareAndSet(key, expected, next, opts?)
Atomically set next only if the current stored value equals expected (compared by value). A missing or expired key is treated as null. Use this for lock-free optimistic updates where a stale writer must not clobber newer data.
Returns:
true if the swap happened, false if the current value didn’t match expected.
storage.increment(key, amount?, opts?)
Atomically add to a numeric value, creating it (starting at 0) if absent.
Returns: The new numeric value after incrementing.
storage.mutate(key, fn, opts?)
Atomically read a value, transform it, and write the result — the safe way to do read-modify-write without losing a concurrent update. fn receives the current value (undefined if the key is absent) and returns the value to store. Returning undefined aborts the write and leaves the stored value untouched.
fn, then commit with a row-locked compareAndSet. If another execution wrote first, it re-reads and runs fn again (a few times) until it commits cleanly.
Returns: The newly stored value (or the unchanged value if
fn returned undefined). Throws if it still can’t commit after several retries (extreme contention).
Example: rotating OAuth refresh tokens (exactly-once)
The motivating case. Providers like AWeber hand out a short-lived access token plus a single-use refresh token that rotates on every refresh — using the old refresh token invalidates it. Two guarantees are needed, andstorage gives you both:
- Durability — the newly rotated refresh token must be persisted for the next run (backend scripts are stateless; there’s nowhere else to keep it).
- Exactly-once refresh — if two requests refresh at the same instant, they’d both spend the same single-use refresh token and one call would fail. Only one request may perform the refresh.
- Refresh before the access token actually expires (while it’s still valid), so a request that doesn’t win the claim can safely keep using the current token — no waiting.
- Use
setIfAbsentas a non-blocking claim: exactly one request wins and refreshes; the others fall through. Because a claim can never outlive its TTL, a crashed run can’t wedge the integration.
Why not refresh inside a locked callback? Two reasons: holding a database lock across an external network call is an anti-pattern, and the script sandbox can only wait on one async operation at a time — a network call nested inside a locked callback isn’t supported and would fail. Keep the network call in your main script flow, guarded by a claim.
Limits per brand:
- 10,000 keys maximum per brand
- 64 KB maximum value size
- No expiry by default (max TTL when set: 1 year)
- 50 storage operations per script execution (a
mutateretry spends aget+ acompareAndSet)