Core Concepts

The design decisions that make Nanoka tick.

80% automatic, 20% explicit

Nanoka derives your DB schema, TypeScript types, and base validation from one model definition. That is the automatic 80%. The remaining 20% — what the API accepts, what it returns, how fields are shaped for specific routes — is kept explicit.

The clearest example is passwordHash:

const User = app.model('users', {
  id:           t.uuid().primary().readOnly(),
  email:        t.string().email(),
  name:         t.string(),
  passwordHash: t.string().serverOnly(), // DB column, never in API output
})

By marking it serverOnly(), Nanoka strips it from every response automatically. You do not need to omit it by hand in every route. The 20% is where you intentionally diverge — for example, a POST /users route that accepts a plain password field, hashes it, and stores the hash:

import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'

const CreateUserBody = User.inputSchema('create').extend({ password: z.string().min(8) })

app.post('/users', zValidator('json', CreateUserBody), async (c) => {
  const { password, ...body } = c.req.valid('json')
  const passwordHash = await hash(password)
  const user = await User.create({ ...body, passwordHash })
  return c.json(User.toResponse(user), 201)
})

This is explicit by design. The framework does not try to guess how you hash passwords.

Model-centric data flow

model definition (app.model)
        |
        +---> DB schema (nanoka generate -> Drizzle schema file)
        |
        +---> TypeScript types (inferred automatically)
        |
        +---> Zod schemas
        |       +---> inputSchema('create')  -- readOnly fields removed
        |       +---> inputSchema('update')  -- readOnly fields removed, all optional
        |       +---> outputSchema()         -- serverOnly fields removed
        |
        +---> Hono validators (User.validator('json', 'create'))
        |
        +---> OpenAPI components (User.toOpenAPIComponent())

The DB shape and the API shape can diverge intentionally. The model is the bridge, not a mirror.

Field policy quick reference

Policy DB column Create input Update input API output
(none) yes yes yes yes
readOnly() yes no no yes
writeOnly() yes yes yes no
serverOnly() yes no no no
  • readOnly — set once (auto-generated UUID, createdAt timestamps). Not accepted by create or update.
  • writeOnly — accepted as input but never returned. Suitable for fields that are stored but must not leak.
  • serverOnly — only ever touched by server-side code. Not accepted as input and not returned. Suitable for passwordHash and similar secrets.

Why schema() and validator() are separate

User.schema(opts) returns a standalone Zod schema. User.validator(target, opts) returns a Hono validator middleware.

Keeping them separate lets you use the schema without Hono — in tests, in background workers, or when composing schemas:

// Use schema() standalone — no Hono dependency needed
const CreateSchema = User.schema({ omit: (f) => [f.passwordHash] })
const parsed = CreateSchema.safeParse(body)

// Use validator() as Hono middleware
app.post('/users', User.validator('json', { omit: (f) => [f.passwordHash] }), handler)

Both accept the field accessor form { pick: (f) => [f.name] } so typos become type errors at compile time.

Hono internalized

Nanoka's router is Hono-compatible. You use Hono patterns throughout:

import { HTTPException } from 'hono/http-exception'

app.get('/users/:id', async (c) => {
  const user = await User.findOne({ id: c.req.param('id') })
  if (!user) throw new HTTPException(404, { message: 'Not found' })
  return c.json(User.toResponse(user))
})

Hono middleware, c.req.valid(), c.env, and the RPC client all work without any adapter layer.

The Drizzle escape hatch

When the model API is not enough, drop down to raw Drizzle:

import { eq } from 'drizzle-orm'

const rows = await app.db
  .select()
  .from(User.table)
  .where(eq(User.table.email, 'alice@example.com'))
  .limit(1)

// Raw DB rows include all columns including serverOnly fields.
// Always pass through toResponse / toResponseMany before returning.
return c.json(User.toResponse(rows[0]))

app.db is a standard Drizzle instance. Any Drizzle feature — joins, aggregates, raw SQL — works here.

No custom migration engine

nanoka generate reads your model definition and writes a Drizzle schema TypeScript file. That is all it does. The diff-to-SQL step and the apply step are delegated to drizzle-kit and wrangler d1 migrations:

app.model(...)  -->  nanoka generate  -->  src/db/schema.ts
                                              |
                              drizzle-kit generate  -->  migrations/*.sql
                                              |
                      wrangler d1 migrations apply  -->  D1 database

This design means Nanoka never needs to maintain its own migration diffing logic or SQL dialect support. You get the full power of the existing toolchain without Nanoka standing in the way.

findMany requires limit

Calling findMany without a limit is a TypeScript type error. This is intentional: unbounded queries against a production database are a common accidental mistake.

// Type error — limit is required
await User.findMany({ offset: 0 })

// Correct
await User.findMany({ limit: 20, offset: 0 })

When you genuinely need all rows — for example, in a background job or a data export — use findAll:

const allUsers = await User.findAll()

findAll makes the intent explicit. Use it knowingly, not as a default.