Skip to content

HTTP Transport

A plugin with no database. It serializes the whole query and sends it to a server that has one.

Quick Navigation

When To Use It

Use this when both ends are yours and both run Routier. The query travels intact, so the server can push a filter to an index, run a real SQL JOIN, or compute an aggregate and return the answer instead of the rows.

Use HttpDbPlugin instead when the server is not yours — it talks to an ordinary REST API, one GET per collection, with filters flattened into query parameters.

Client

The client has no local storage at all:

ts
app.post("/routier", async (req, res) => {
  const answer = await handle(req.body, context(req));

  res.status(answer.ok ? 200 : 403).json(answer);
});

Then query it like any store. Nothing about the calling code changes:

ts
authorize: ({ action, collectionNames, context }) => {
  if (action !== "query") return "this endpoint is read-only";
  if (collectionNames.includes("audit")) return "audit is not readable";
  return context.userId != null;
}

That is one request, and the join executes on the server.

Server

createRequestHandler is one async function from JSON to JSON. It knows nothing about HTTP, so it works behind Express, a Cloudflare Worker, a Lambda, a WebSocket, or a worker postMessage:

ts
createRequestHandler({ plugin, schemas, allowDestroy: true })

Failures come back as { ok: false, error } rather than as a thrown exception, so a query error cannot accidentally become a 500 with no body. You choose the status code — only your route knows whether "not signed in" is a 401 or a 403:

ts
app.post("/routier", async (req, res) => {
  const answer = await handle(req.body, context(req));

  res.status(answer.ok ? 200 : 403).json(answer);
});

Whatever status you pick, return the body. The client reads it: a non-2xx carrying a Routier answer surfaces the error message the handler wrote, and only a response with no Routier answer in it is reported as a transport failure. Sending a bare status would tell the caller "returned 403" instead of which rule they broke.

Securing The Endpoint

Routier supplies no policy — no user, no tenant, no role — because it cannot know what yours are. It supplies two places a decision cannot be forgotten, and enforces both where the caller cannot reach.

Both receive a context you build from the request. Build it from the request, never from the body — the body is the part the client controls.

ts
app.post("/routier", async (req, res) => {
  const answer = await handle(req.body, context(req));

  res.status(answer.ok ? 200 : 403).json(answer);
});

Name a context type and passing one becomes required, so a policy cannot be half-wired.

authorize — may this caller do this at all?

Called once per request, before anything is deserialized or executed. Return true to allow, or false/a string to refuse — the string becomes the error message.

It is told the action and every collection the request touches, joins included, so a policy can refuse a join to a collection it would refuse directly:

ts
authorize: ({ action, collectionNames, context }) => {
  if (action !== "query") return "this endpoint is read-only";
  if (collectionNames.includes("audit")) return "audit is not readable";
  return context.userId != null;
}

scope — which rows may this caller touch?

A filter, written exactly like a collection's own .scope(). Return null for a collection this caller may see in full. It is enforced in three places:

  • Reads — ANDed in first, so no option the caller sends can displace it, and it is pushed down to the database rather than filtered afterwards.
  • Joins — applied to the inner side too. Without that, a join would be a way to read an unscoped view of any other collection.
  • Writes — every added, updated and removed row is checked against the same filter, and one row outside it refuses the whole save. Otherwise a caller who can only read their own rows could still write somebody else's.

A scope that cannot be expressed as a filter is refused, not ignored — it is the boundary between callers, and one that quietly stops applying is worse than an error.

destroy is refused by default

HttpTransportDbPlugin never sends one, but an endpoint answers whatever arrives. A hand-written {"kind":"destroy"} would otherwise drop your database, so it is opt-in:

ts
createRequestHandler({ plugin, schemas, allowDestroy: true })

It still passes through authorize.

With no hooks

An endpoint with neither hook answers anything for anyone, for read and write, for every collection in schemas. That is the correct default for a function with no idea who is calling it — and the reason to reach for the hooks above rather than a check in the route alone.

What Crosses The Wire

Everything below travels as JSON and is rebuilt against the server's schemas. Collections are named, never described, so the server's definition of a collection is always the one that applies:

SentNotes
whereAs an expression tree, so the server can push it to an index or a WHERE clause
sort, skip, takeThe selector is rebuilt from the property on arrival
count, min, max, sum, distinctThe server returns the answer, not the rows
join / leftJoinIncluding the inner side's scopes — one request, not two
nearestVector and count
SavesAdds, updates and removes, with the echo returned so identities come back

What Stays Local

map and group are defined by a closure, and a closure cannot be serialized. The plugin sends the longest prefix it can and runs the rest itself:

ts
// `where` is sent; `map` runs on the client, over the rows the server returned
await store.members.where(m => m.rank > 15).map(m => m.name).toArrayAsync();

It is a prefix rather than a filtered subset, and that matters: sending count while keeping map local would count unmapped rows.

Limits

  • No caching, no offline queue, no retry. This is a transport, so what arrives at the server is exactly what the caller asked for. Compose HttpSwrDbPlugin when you want those.
  • destroy is not forwarded. It means "release what this plugin holds", and this plugin holds a URL. Forwarding it would let any client drop the server's database.
  • Both ends need the same collection names. A name the server does not serve is refused with an error rather than an empty result.
  • Subscriptions are local. Change notifications come from your own store's writes; this plugin does not open a channel to the server.
  • Joins — what a forwarded join does on the far side
  • ReplicationHttpDbPlugin and the SWR plugin
  • Plugins — the plugin contract

Released under the MIT License.