Skip to content

helpers

Eugene Lazutkin edited this page Jul 4, 2026 · 5 revisions

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']);

io.update(target, fn, options?)

An optimistic-concurrency read-modify-write. update:

  1. GETs the current representation with its envelope (cache bypassed), capturing the ETag.
  2. Calls fn(data) to produce the next value (fn may be async).
  3. If fn returns undefined, nothing is written — the current data is returned as-is.
  4. Otherwise PUTs the result with If-Match: <etag>, so the server rejects the write if the resource changed underneath you.
  5. On success, invalidates the target's cache entry — keyed by the full built URL, query included — when the cache is active, and returns the PUT result.

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.

io.paginate(url, data?, options?)

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:

  1. Body links — an envelope with a links object follows links.next (relative URLs resolve against the response); a missing/null next is the last-page signal.
  2. Cursor — an envelope with a cursor echoes it back (?cursor=…) until the server returns a null/absent cursor.
  3. Offset — an envelope echoing a numeric offset advances by items.length (never by the requested limit — servers clamp), reuses the echoed effective limit, and stops at offset + items.length >= total, on a page shorter than limit, or on an empty page.
  4. Bare arrays — pages by the Link response 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).

io.getByIds(url, ids, options?)

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).

See also

Clone this wiki locally