Joins
Stitch related rows from one lens onto another without caring which
database each lens lives in. A single include clause on search,
similar, query, or their streaming cousins returns parent rows
with their relations already attached.
No SQL JOIN written, no FK picked, no second round-trip. The join planner
resolves the relation you declared once, fans out a batchRead to each
target bridge, and stitches the results.
Joins are a first-class feature, not a last-mile hack. The same
primitive works whether both sides live in one Postgres or whether one
side is Postgres and the other is MySQL, Mongo, or DynamoDB. You change
the source in your config — the join keeps working.
What you get
- Declarative relations in
sl.config.ts—hasMany/belongsTowith a single key mapping. No SQL in your config file. - Cross-source planning — same-DB relations stay cheap, different-DB relations use an in-memory hash-join via each bridge's
batchReadcapability. - Typed return shapes — generated Beam clients expose
recipe.reviews[0].ratingas a fully-typed path. - Per-parent caps (
defaultIncludeLimiton the relation, overridable per-call) with a hard system ceiling of 1000 so a chatty caller can't blow up the server. - Fail-partial semantics — if one relation's bridge is down or an access rule denies, that relation comes back empty for this request and
meta.includeErrorssurfaces why. Primary rows still land.
Where joins attach
Same include shape, different response locations — the data lives where
the data naturally lives:
| Op | Relations land at |
|---|---|
query() | rows[i][relationName] — flat on the row |
search() | results[i].metadata[relationName] — on the metadata object |
similar() | results[i].metadata[relationName] — same as search |
feed() | items[i].metadata[relationName] — same as search |
stream.query() / stream.search() | Per-batch, attached to each row before the row yields |
The three things to learn
- Declaring relations — the
relationskey onLensConfig, the two kinds, howonworks. - Include syntax — the
includeparam on every read op (HTTP, streaming, typed + untyped clients, CLI). - Cross-source joins — how the planner picks a strategy and what happens if a bridge doesn't support
batchRead.
When to use joins vs. separate queries
Reach for include when:
- You'd otherwise fetch the primary row, then fire off a second round-trip to look up related rows by FK. The planner batches these across all parents in one call — far cheaper than N per-row lookups.
- You're rendering parent + related in the same view (dashboard cards, detail pages, feed items with their author / reviews / tags).
- The relationship is declared and stable.
includedoesn't support ad-hoc joins on arbitrary columns — those live in regularquerywith a SQL-capable bridge.
Stick with separate query calls when:
- You need an aggregate across the joined side (
avg,sum,count) — aggregations are a separate primitive, not a flavour ofinclude. - The "join" is really a child-side filter: "recipes where any ingredient name contains 'chocolate'". Query the child lens directly, collect parent ids, pass them via
where: { id: { $in: [...] } }on the parent.