-
Notifications
You must be signed in to change notification settings - Fork 0
helpers
Higher-level glue built on the core verbs: io.update turns a read-modify-write into a safe conditional PUT with automatic conflict retry, io.paginate iterates a paged collection row by row, and io.getByIds batch-reads a set of ids in one request.
import io from 'double-meh';
// bump a counter without clobbering a concurrent writer
const updated = await io.update('https://example.com/u/1', cur => ({
...cur,
n: cur.n + 1
}));
// walk a paged collection as if it were one list
for await (const product of io.paginate('/products', {category: 'audio'})) {
render(product);
}
// one request for a hand-picked set of ids
const selection = await io.getByIds('/products/by-ids', ['ap-31', 'ap-77']);An optimistic-concurrency read-modify-write. update:
-
GETs the current representation with its envelope (cache bypassed), capturing theETag. - Calls
fn(data)to produce the next value (fnmay be async). - If
fnreturnsundefined, nothing is written — the current data is returned as-is. - Otherwise
PUTs the result withIf-Match: <etag>, so the server rejects the write if the resource changed underneath you. - On success, invalidates the target's cache entry — keyed by the full built URL, query included — when the cache is active, and returns the
PUTresult.
If the PUT comes back 412 Precondition Failed, update re-reads the resource, re-applies fn to the fresh data, and retries — up to 3 conflict retries before the BadStatus error is rethrown. Any non-412 error propagates immediately.
The ETag is what makes the write safe, so when the initial GET returns none, update throws an IOError rather than risk a blind overwrite. Pass {force: true} to update unconditionally — with no ETag the PUT then goes out without If-Match.
await io.update('/api/no-etag/1', doc => ({...doc, seen: true}), {force: true});// read-modify-write: append a tag, retrying if someone else writes first
const result = await io.update('/api/doc/42', doc => ({
...doc,
tags: [...doc.tags, 'urgent']
}));Return undefined from fn to abort the write conditionally — a convenient no-op when the current state already satisfies you:
await io.update('/api/doc/42', doc => (doc.archived ? undefined : {...doc, archived: true}));options (third argument) is forwarded to both the GET and the PUT, so you can pass headers, query, signal, and other per-request settings. Note that cache: false (on the read) and ifMatch (on the write) are set for you.
target may be a URL string, a URL, or an options object with a url — the same shapes the core verbs accept.
An async iterable of rows across a paged collection — the request fires on the first iteration, pages load as you consume, and the loop ends at the collection's end. It understands the common paging shapes and picks per response:
-
Body links — an envelope with a
linksobject followslinks.next(relative URLs resolve against the response); a missing/nullnextis the last-page signal. -
Cursor — an envelope with a
cursorechoes it back (?cursor=…) until the server returns anull/absent cursor. -
Offset — an envelope echoing a numeric
offsetadvances byitems.length(never by the requested limit — servers clamp), reuses the echoed effectivelimit, and stops atoffset + items.length >= total, on a page shorter thanlimit, or on an empty page. -
Bare arrays — pages by the
Linkresponse header (rel="next"), GitHub-style.
The page array is data in an envelope ({data, offset, limit, total?, links?}) or the body itself; anything else throws FailedIO. A continuation that repeats a page (a self-referential next, a stuck cursor) also throws FailedIO instead of looping forever.
// start at a specific page and size — the page option lowers to ?offset=&limit=
for await (const row of io.paginate('/products', null, {page: {offset: 40, limit: 20}})) {
render(row);
}
// stop early: breaking out is fine — nothing else is fetched
for await (const row of io.paginate('/logs', {level: 'error'})) {
if (isOld(row)) break;
}data and options behave exactly like io.get's — query dicts, headers, signal, cache, and the page: {offset, limit, cursor} option (which also works on plain verbs).
A batch read for a set of identifiers: one request instead of N. The natural form is a plain, cacheable GET with the list comma-joined into one ids parameter; when the built URL would exceed io.getByIds.urlLimit (default 2000 characters), it falls back to a POST carrying {keys: [...]} in the body — still a read, at the cost of cacheability.
const result = await io.getByIds('/users/by-ids', ['yr8', '6Rc', '3kTMd']);
// GET /users/by-ids?ids=yr8,6Rc,3kTMd
// → {data: [{...}, {...}, null]} — length-preserving, misses come back as null
// thousands of ids? the list moves to a POST body automatically
await io.getByIds('/users/by-ids', manyIds);Other request options compose — fields/expand lowering and query params stay on the URL in both forms. The response passes through as-is (conventionally the length-preserving {data: [...]} envelope, in the caller's order).
Guides
Concepts
Services
Modules
- fetch transport
- keys & URLs
- helpers
- records
- sse
- storage backends
- compression encoders
- Service Worker integration
Cookbook