SemiLayerDocs

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

interface RelationConfig {
  lens: string                          // target lens name (same environment)
  kind: 'hasMany' | 'belongsTo'         // array vs single row
  on: Record<string, string>            // { localColumn: foreignColumn } — exactly one pair in v1
  defaultIncludeLimit?: number          // per-parent cap for hasMany. Default 100, max 1000.
}

Wrapped on the lens:

lenses: {
  recipes: {
    fields:  { /* ... */ },
    grants:  { /* ... */ },
    relations: {
      reviews: {
        lens: 'reviews',
        kind: 'hasMany',
        on:   { id: 'recipe_id' },
        defaultIncludeLimit: 10,
      },
      ingredientRows: {
        lens: 'ingredients',
        kind: 'hasMany',
        on:   { id: 'recipe_id' },
        defaultIncludeLimit: 20,
      },
    },
  },
}

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 } }).

recipes: {
  relations: {
    reviews: {
      lens: 'reviews',
      kind: 'hasMany',
      on:   { id: 'recipe_id' },       // recipes.id → reviews.recipe_id
    },
  },
}

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.

reviews: {
  relations: {
    recipe: {
      lens: 'recipes',
      kind: 'belongsTo',
      on:   { recipe_id: 'id' },       // reviews.recipe_id → recipes.id
    },
  },
}

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).

DirectionReads 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).

reviews: {
  lens: 'reviews',
  kind: 'hasMany',
  on:   { id: 'recipe_id' },
  defaultIncludeLimit: 10,          // ← default cap
}

// Caller overrides
include: { reviews: { limit: 50 } }  // ← works, within 1000 ceiling
include: { reviews: true }           // ← uses 10
include: { reviews: { limit: 5000 } } // ← clamped to 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:

  • lens exists in the same environment. Typo → CONFIG_INVALID_RELATION_TARGET.
  • kind is one of 'hasMany' | 'belongsTo'.
  • on has 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) is 1 ≤ 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:

recipes: {
  relations: {
    reviews: { lens: 'reviews', kind: 'hasMany', on: { id: 'recipe_id' } },
  },
}

reviews: {
  relations: {
    recipe: { lens: 'recipes', kind: 'belongsTo', on: { recipe_id: 'id' } },
  },
}

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.