8.9 Loadable storage adapters (bring your own backend)

The file, data, and query services ship with built-in backends — local files, durable file-backed data, in-memory data, and the reference query adapter (4.7). A loadable storage adapter lets you back any of those mounts with your own deployed code instead, so a data mount can persist to Redis, a file mount to an object store, a query mount to a real database — without rebuilding the runtime.

This is the answer to "RS2 doesn't have an adapter for my database." You write one as a normal custom service (Part 8) and point a mount at it.

Loadable adapters run on the JavaScript engine (they use the socket capability, 8.5). A node built without the JS engine rejects an adapter-backed mount at config time with 501.

The idea: an adapter is a message-shaped backend

An adapter is a normal custom JS bundle, but the host sends it a small, service-kind-specific message surface:

Mount kind Adapter conversation
data Store-shaped GET/PUT/DELETE /{dataset}/{key}, trailing-slash listings, schemas, and confirmed dataset delete
file HEAD/GET/PUT/DELETE/MOVE plus listings; file content crosses the JSON boundary as base64
query One POST /query with {query, params, take, skip} returning {rows, total}

Your bundle translates those messages into the backend's wire protocol (via a socket or httpOut grant, 8.5) and translates the result back. The query service performs template substitution and parameter validation before its adapter call; a file adapter's content is materialized at this boundary, while the host rebuilds the versioned body used for ETags, ranges, and 304s.

You do not reimplement schema validation, ETags, or the .schemas surface. The stock service runs unchanged on top of your adapter: a data mount still validates against its schemas and emits ETags; your adapter only handles persistence. You implement the storage; the service keeps the contract.

Wiring a mount to an adapter

Two steps. First, deploy the adapter bundle like any custom service (8.6):

rs2 deploy redis-adapter.js --name my-redis --bundle

Then point a data (or file / query) mount at it through a store block:

{ "path": "/data", "service": "data",
  "config": {
    "store": {
      "adapter": "code:my-redis@v1",
      "grants": { "db": { "type": "socket", "hosts": ["redis.internal:6379"] } },
      "host": "redis.internal", "port": 6379
    },
    "access": { "read": "all", "write": "A" }
  } }
  • adapter — the deployed code ref (code:<name>@<version>, 8.7). Immutable and versioned like any custom service; bump the version to roll the backend.
  • grants — the adapter's capabilities. An adapter only needs its socket grant; request/fetch are default-deny for adapters.
  • Connection params — the entire store block is handed to your bundle as ctx.config, so put host, port, and other non-secret connection options there. Keep credentials host-side with an injected grant or an operator infra (10.7), not in the adapter's visible config.

The same store block works on file, data, and query mounts — each loads its adapter lazily on first request.

Resident runtimes: connections pool across requests

A normal custom service gets a fresh sandbox per request (8.2), which would mean reconnecting to your database on every call. Adapters are different: they run as resident runtimes — the bundle is evaluated once and kept warm on its own thread, servicing many requests against the same isolate. A connection your adapter opens stays open for the next request, so you can pool it in an ordinary module-level variable. (Appendix G §10 covers the mechanism.)

Two optional store keys tune the pool:

Key Default Meaning
maxRuntimes 4 Max warm runtimes per mount; the pool grows lazily under concurrent load (each runtime serializes its own jobs)
idleMs (or idleSeconds) 60000 / 60 Evict a runtime idle longer than this, closing its pooled connections. 0 disables eviction

A serial workload stays at a single runtime; the pool only grows when concurrent requests would otherwise queue. A config change rebuilds the tenant, which drops the whole pool (and its connections) and lets the next request spin up the new version — that's how a new adapter version cleanly replaces the old one.

Reference adapters

The project ships proven adapters you can use as starting points:

  • MongoDB data (guest-adapters/mongo-data.js) — datasets map to collections, record keys to string _id values, with schemas kept in a dedicated collection.
  • MongoDB query (guest-adapters/mongo-query.js) — runs aggregation pipelines and returns rows plus total in one round trip.

Use them as templates: an adapter is just store-pattern message handling plus a wire-protocol client built on RS2Socket. These examples currently assume a network-trusted, no-auth MongoDB. SCRAM-SHA-256 needs WebCrypto primitives the sandbox does not yet expose.

When to reach for this

You want Use
Store JSON records in Postgres/Mongo/Redis a data adapter
Serve files from an object store or remote FS a file adapter
Push queries down to a real database engine a query adapter
Just call an external API from a handler a plain service + fetch grant (8.5) — not an adapter

If you only need to call a backend from within one handler, you don't need an adapter — a normal custom service with a fetch or socket grant is simpler. Reach for a loadable adapter when you want a whole store mount (with schemas, ETags, listings, and the agent surface) backed by your own persistence.


Previous: 8.8 Limits and diagnosing failures · Manual home · Next: 8.10 Proxy and SMS services →