Declaring Relations
Relations live on each lens in sl.config.ts — a relations block next
to fields and grants. Declare them once; every include call across
search, query, similar, and feed uses them.
The shape
Wrapped on the lens:
The two kinds
hasMany — one parent, many children
rows[i][relationName] is an array. The planner fetches up to
defaultIncludeLimit children per parent (callers can narrow it further
per-call with include: { rel: { limit: N } }).
belongsTo — child points back at parent
rows[i][relationName] is a single object (or null if no match). No
limit applies — by definition there's at most one.
Both directions are usually declared — one on each lens. That way a caller can start from either end and include the other.
The on clause
on is a Record<string, string> with exactly one pair. The key is
the column name on the lens you're declaring the relation on
(the "local" side). The value is the column name on the target
lens (the "foreign" side).
| Direction | Reads as |
|---|---|
hasMany: { id: 'recipe_id' } on recipes | "each recipes.id matches many reviews.recipe_id" |
belongsTo: { recipe_id: 'id' } on reviews | "each reviews.recipe_id matches one recipes.id" |
Both columns refer to mapped field names on their respective
lenses — after the lens's from / transform mapping runs. Use the
output name you see in the lens's fields block, not the source column
name.
Composite keys are not supported in v1. If your FK is actually two
columns (e.g. (tenant_id, recipe_id) → (tenant_id, id)), you'll need
to project a synthetic single-column key via a from / transform
field on both sides, then declare the relation against that.
defaultIncludeLimit
The safety knob for hasMany relations. When a caller omits limit on
a specific include, the planner uses defaultIncludeLimit. If they pass
limit explicitly, that wins (up to a system ceiling of 1000).
Keep defaultIncludeLimit honest — it's the number you want most
callers to get. Burning through 1000 reviews per recipe by default wastes
the bridge's time.
Validation
semilayer push validates:
lensexists in the same environment. Typo →CONFIG_INVALID_RELATION_TARGET.kindis one of'hasMany' | 'belongsTo'.onhas exactly one key/value pair. Multi-pair →CONFIG_INVALID_RELATION_ON.- Local column is a declared field on the owning lens.
- Foreign column is a declared field on the target lens.
defaultIncludeLimit(if set) is1 ≤ N ≤ 1000.
A failed validation blocks the push — the lens doesn't enter paused
until the config is valid.
Bidirectional declarations
A relation on one side doesn't imply the reverse. If you want
recipes.include({ reviews }) AND reviews.include({ recipe }), declare
both:
Each side only authorizes traversal from its own starting point. Access rules are also enforced per-side — see Access Rules.
Next: Include syntax — how callers use the relations you just declared.