Relations

Nanoka supports depth-1 eager loading via t.hasMany() and t.belongsTo(). The implementation uses two queries and JavaScript grouping — not SQL JOINs.

Overview

  • 2 queries + JS grouping: parent rows are fetched first, then child rows are fetched with an IN (...) query and grouped in JavaScript.
  • JOIN is not used: this avoids cartesian product issues, differences in D1/libSQL JOIN support, and re-inventing Drizzle's relation DSL.
  • Depth is limited to 1. Nested with (e.g. posts.comments) is not supported in v1.

Field Builders

Define relations in your model registration, not in the model file. Relation fields have no DB column and are skipped by nanoka generate.

import { t } from '@nanokajs/core'

// 1 → N: User has many Posts
t.hasMany(target, { foreignKey })

// N → 1: Post belongs to a User
t.belongsTo(target, { foreignKey })

foreignKey is always required — Nanoka does not infer it.

  • hasMany: foreignKey is the column name on the target model (e.g. 'userId' on Post).
  • belongsTo: foreignKey is the column name on the current model (e.g. 'userId' on Post).

Thunk form for bidirectional relations

When two models reference each other, use a thunk (() => Target) on at least one side to avoid temporal dead zone (TDZ) errors:

// biome-ignore lint/suspicious/noExplicitAny: cyclic model graph requires forward declaration
let User: any

const Post = app.model('posts', {
  ...postFields,
  author: t.belongsTo(() => User, { foreignKey: 'userId' }),
})

User = app.model('users', {
  ...userFields,
  posts: t.hasMany(() => Post, { foreignKey: 'userId' }),
})

The thunk is evaluated lazily at query time, so both models are fully defined before any query runs.

Query API

Pass a with option to findMany or findOne to load relations eagerly.

// Load user with their posts
const user = await User.findOne(id, { with: { posts: true } })
// { id, name, email, createdAt, posts: [{ id, userId, title, ... }] }

// Load post with its author
const post = await Post.findOne(id, { with: { author: true } })
// { id, userId, title, createdAt, author: { id, name, email, ... } | null }

// findMany also supports with
const users = await User.findMany({ limit: 20, with: { posts: true } })
// [{ id, name, ..., posts: [...] }, ...]

Return type:

  • hasMany: a RowType<TargetFields>[] array is appended to the parent row.
  • belongsTo: a RowType<TargetFields> | null object is appended to the parent row.

Constraints

Constraint Detail
Depth 1 only. Nested with (e.g. posts.comments) is not supported in v1.
Parent limit Required on findMany — same as without with.
Child limit Not applied — all matching child rows are returned.
where on relations Not supported. Use app.db for filtered joins.
FK SQL constraint Not auto-generated. Add references() manually in drizzle/schema.ts if needed.
Validator / schema Relation fields are excluded from inputSchema(), outputSchema(), and validator() by default.

OpenAPI Integration

Use toOpenAPISchema('output', { with }) to expand relations in OpenAPI spec output. This is spec-only — runtime validation source of truth remains Zod.

// Expand posts array in the User output schema
User.toOpenAPISchema('output', { with: { posts: true } })
// → { type: 'object', properties: { ..., posts: { type: 'array', items: { ... } } } }

// Expand author object in the Post output schema
Post.toOpenAPISchema('output', { with: { author: true } })
// → { type: 'object', properties: { ..., author: { type: 'object', nullable: true, ... } } }

When with is not passed, relation fields are excluded from the schema (default behavior).

Important: The OpenAPI spec is for documentation only. Runtime validation always uses Zod schemas (inputSchema() / outputSchema() / validator()).

Migration Notes

nanoka generate skips relation fields — they have no DB column. The generated drizzle/schema.ts will not include t.hasMany() or t.belongsTo() entries.

If you need a foreign key constraint in SQL:

  1. Run nanoka generate to get the base schema.
  2. Manually add references() to the relevant column in drizzle/schema.ts:
// Edit drizzle/schema.ts manually after generation
userId: text('userId').notNull().references(() => users.id)
  1. Run drizzle-kit generate to produce the migration SQL.

When to Use the Escape Hatch Instead

Relations API covers the common depth-1 eager loading case. For anything more complex, use app.db directly:

Use case Recommendation
Load user with posts User.findOne(id, { with: { posts: true } })
Filter posts by a condition app.db with Drizzle WHERE clause
Aggregate (count posts per user) app.db with Drizzle aggregate functions
Multi-level nesting (posts + comments) app.db with joins
Related-model where (posts where title = …) app.db with inner join

See Escape Hatch for examples.