Joins — Quickstart
Your users sit in Postgres. Your orders stream into DynamoDB. Your live cart state lives in Redis. Your reviews are in a second MySQL instance another team owns.
That shouldn't require four backend round-trips and a service layer to stitch. It's one read:
Each relation resolves on its own bridge, fans out in a batched
{ $in: [parent_ids] } read, and lands back on each parent row —
typed, stitched, in one response. The planner doesn't care which
database is which. It just asks each bridge for its batch.
23 first-party bridges ship today from the
bridges monorepo — every
major relational store, every popular document and key-value system,
the analytical warehouses, and the edge-native platforms. The join
code below works identically regardless of target bridge. Any adapter
that implements batchRead participates.
The magic trick
No SQL. No N+1. No client-side stitching. No backend service that knows
how to talk to all three databases. Just include.
Four moves
Declare each lens on its own source
Each lens names its bridge in sources. Different bridges are just
different entries in the same map:
The planner doesn't care that they're different bridges. It just asks each one for its batch at query time.
Declare relations on both sides
Bidirectional is the norm — one relation on each lens lets callers start from either end:
Either direction works in the include clause. beam.users.query(...) can
pull in orders; beam.orders.query(...) can pull in the user.
See Declaring Relations for the two kinds
(hasMany vs. belongsTo) and the on semantics.
Push
Validation runs against the full config — each lens, each relation,
every on mapping. Unknown target lenses fail loudly. Missing bridge
packages on the worker fail at first ingest, not at push.
Call with include
Full payload is typed end-to-end. rows[i].recentOrders[0].placed_at
is a typed field derived from the orders lens's declaration — no
manual type work.
What just happened under the hood
For each request, the planner:
- Runs the primary query on the owning lens's bridge (Postgres for
users). - Collects the parent keys (every
users.id). - For each included relation, resolves the target's bridge and calls its
batchReadwith{ <fk>: { $in: [keys] } }. Same-bridge relations share a connection; different-bridge relations each run on their own. - Groups and slices the results per parent (orderBy + limit applied in-process).
- Attaches to each parent row and responds.
Every join — same-DB or cross-DB — is a hash-join. No SQL pushdown, no
federated query engine, no network-of-planners voodoo. Two round-trips
per relation. Predictable latency. Works anywhere batchRead is
implemented.
The rules still apply
Every lens enforces its own access rules independently. A caller who
can see users but not reviews gets rows back with
recentReviews: [] and a meta.includeErrors entry — primary data
still lands, the denied relation fails quietly.
This means you can mix public lenses and authenticated lenses in one call, and the planner does the right thing per-relation. See Access Rules for the full composition story.
Where to go next
- Declaring Relations —
hasMany/belongsTo,onsemantics, composite-key limitations. - Include Syntax — every knob on the include clause (
where,select,orderBy,limit). - Cross-Source Joins — the planner's strategy in detail, connection reuse, 1000-row ceiling.
- Streaming —
stream.query/stream.searchwith relations attached per-batch. - Access Rules — most-restrictive-wins composition, fail-partial semantics.
- Bridges Without Support — what happens when a target bridge opts out of
batchRead.