Generators

The generators module produces TypeScript source files from your schema definitions. Strav provides three generators: a model generator, an API generator, and a route generator.

All generators share a common configuration system and produce barrel exports (index.ts) for each output directory.

Configuration

Output directories are configured via config/generators.ts:
// config/generators.ts
export default {
  paths: {
    // Generated file paths
    // models: 'app/models',
    // enums: 'app/enums',
    // controllers: 'app/http/controllers',
    // services: 'app/services',
    // events: 'app/events',
    // validators: 'app/validators',
    // policies: 'app/policies',
    // resources: 'app/resources',
    // routes: 'start',

    // Database paths (for schemas and migrations)
    // schemas: 'database/schemas',      // Where schema definitions are located
    // migrations: 'database/migrations'  // Where migration files are generated
  },
  modelNaming: {
    public: null,      // No prefix for public models: User, Project
    tenants: 'Tenant'  // "Tenant" prefix for tenant models: TenantExperiment, TenantBelief
  }
}

Override any path to change where generated files are placed. Unspecified paths use the defaults shown above. Cross-directory imports (e.g. controller → service) are computed automatically from the configured paths.

Database Paths

The schemas and migrations paths allow you to customize where your database files are located:
  • schemas: Directory containing your schema definitions (default: database/schemas)
  • migrations: Directory where migration files are generated and stored (default: database/migrations)

This is useful when you want to organize your database files in a different structure, for example:

export default {
  paths: {
    schemas: 'src/db/schemas',
    migrations: 'src/db/migrations'
  }
}

Model Naming

The optional modelNaming configuration allows you to add prefixes to generated model class names. This is useful in multi-tenant architectures to avoid naming conflicts:
// config/generators.ts
export default {
  modelNaming: {
    public: null,        // Public models: User, Project (no prefix)
    tenants: 'Tenant'    // Tenant models: TenantExperiment, TenantBelief
  }
}

When prefixes are configured:

  • Class names get prefixed: class TenantExperiment extends BaseModel
  • Type references use correct prefixes: declare project: Project (public) vs declare experiment: TenantExperiment (tenant)
  • Cross-scope references work automatically: tenant models can reference public models without prefixes
  • Decorator strings use the appropriate model names: @reference({ model: 'TenantProject', ... })
  • Barrel exports reflect the prefixed names: export { default as TenantExperiment }
File names remain unchanged (experiment.ts), only the class names are prefixed.

Barrel exports

Every directory with generated files gets an index.ts barrel file. This allows single-import access to all generated classes:
import { User, Project, Task } from '../models'
import { UserService, ProjectService } from '../services'
import { UserEvents, ProjectEvents } from '../events'
Barrel files use export { default as X } for default-exported classes (models, controllers, services, policies) and export * from for named exports (enums, events, validators).

Prettier formatting

When writing files to disk (writeAll()), all generated TypeScript files are automatically formatted with Prettier. The formatter resolves the project's .prettierrc config from each file's location, so your formatting preferences are applied consistently.

If Prettier is not installed, files are written unformatted.

ModelGenerator

Generates model classes and enum files from schemas. It operates entirely in memory — no database connection is needed.

Usage via CLI

# Generate models for all scopes
bun strav generate:models

# Generate models for specific scope
bun strav generate:models --scope=public    # Only public models
bun strav generate:models --scope=tenants   # Only tenant models
bun strav generate:models --scope=all       # Both scopes (default)

When using multi-tenant architecture with separate schemas, models are generated in scoped directories:

  • app/models/public/ - System-wide models (users, organizations, etc.)
  • app/models/tenants/ - Tenant-specific models (application data)

Programmatic usage

import { ModelGenerator } from '@strav/cli'
import type { GeneratorConfig } from '@strav/cli'

const config: GeneratorConfig = {
  paths: { models: 'app/models', enums: 'app/enums' },
  modelNaming: {
    public: null,      // No prefix for public models
    tenants: 'Tenant'  // "Tenant" prefix for tenant models
  }
}

// Generate for a specific scope
const generator = new ModelGenerator(schemas, representation, config, 'public')
// Or: new ModelGenerator(schemas, representation, config, 'tenants', allSchemasMap)

// Generate in memory
const files = generator.generate()
// files = [{ path: 'app/models/public/user.ts', content: '...' }, ...]

// Generate and write to disk
const written = await generator.writeAll()
The config parameter is optional — defaults are used when omitted. The scope parameter appends /public or /tenants to the configured model and enum paths.

What gets generated

Model files

For each non-association schema, a model file is produced in app/models/:
// app/models/user.ts — Generated by Strav -- DO NOT EDIT
import { DateTime } from 'luxon'
import BaseModel from '@strav/database'
import { primary, associate } from '@strav/database'
import { UserRole } from '../enums/user'
import type Team from './team'

export default class User extends BaseModel {
  static override softDeletes = true

  @primary
  declare pid: string

  declare username: string | null
  role: UserRole = UserRole.User

  declare createdAt: DateTime
  declare updatedAt: DateTime
  declare deletedAt: DateTime | null

  @associate({ through: 'team_user', foreignKey: 'user_pid', otherKey: 'team_id', model: 'Team', targetPK: 'id' })
  declare teams: Team[]
}

Enum files

Enums are grouped by their owning entity in app/enums/:
// app/enums/user.ts — Generated by Strav -- DO NOT EDIT
export enum UserRole {
  User = 'user',
  Admin = 'admin',
  Staff = 'staff',
  Visitor = 'visitor',
}

Generation rules

Property declarations

  • declare keyword: Used for properties without a schema default (DB-generated PKs, timestamps, nullable fields). These are invisible to Object.keys() on a fresh instance, preventing them from being included in INSERT statements.
  • Initializer: Used for properties with a schema .default(). These get included in INSERT statements.

Type mapping

PostgreSQL type TypeScript type
serial, integer, smallint, real, double number
bigserial, bigint bigint
varchar, text, uuid, char string
boolean boolean
timestamp, timestamptz DateTime
json, jsonb Record<string, unknown>
Custom enum Generated enum type (e.g., UserRole)

Relationships

  • @reference: Generated for schema parent fields and t.reference() fields. Produces a FK property and a reference property.
  • @associate: Generated from defineAssociation schemas. Both sides of the many-to-many get an @associate decorator.

Soft deletes

If the archetype includes a deleted_at timestamp, the model gets static override softDeletes = true and a declare deletedAt: DateTime | null property.

ApiGenerator

Generates the full application layer from schemas: event constants, validators, policies, services, and controllers. Every generated file is archetype-aware.

Usage via CLI

bun strav generate:api

Programmatic usage

import { ApiGenerator } from '@strav/cli'
import type { GeneratorConfig } from '@strav/cli'

const config: GeneratorConfig = {
  paths: { controllers: 'app/controllers/api' },
}

const generator = new ApiGenerator(schemas, representation, config)

// Generate in memory
const files = generator.generate()

// Generate and write to disk
const written = await generator.writeAll()
The config parameter is optional — defaults are used when omitted.

What gets generated

For each non-association schema, 6 files are produced:

Event constants — app/events/{name}.ts

Named constants for use with the Emitter. Events vary by archetype:

// app/events/user.ts
export const UserEvents = {
  CREATED: 'user.created',
  UPDATED: 'user.updated',
  SYNCED:  'user.synced',
  DELETED: 'user.deleted',
} as const
Archetype Events
entity, attribute, contribution, reference CREATED, UPDATED, SYNCED, DELETED
component UPDATED, SYNCED
event CREATED
configuration UPDATED, SYNCED

Validator rules — app/validators/{name}_validator.ts

Auto-generated from field definitions. Each validator has store and update rule sets:
// app/validators/user_validator.ts
export const UserRules: Record = {
  store: {
    username: [required(), string()],
    role: [oneOf(['user', 'admin', 'staff'])],
  },
  update: {
    username: [string()],
    role: [oneOf(['user', 'admin', 'staff'])],
  },
}
Rules are derived from field type (pgType → type rule), required flag (→ required() on store only), enum values (→ oneOf()), varchar length (→ max()), and schema validators (min, max, email, url, regex). Reference FK fields (e.g., authorPid from t.reference('user')) are included using their FK column name and validated with the referenced PK's type rule.

System-managed fields are excluded: id, timestamps, parent FK.

Policy skeleton — app/policies/{name}_policy.ts

A class with methods per archetype. All default to return true — customize the logic:
// app/policies/user_policy.ts
export default class UserPolicy {
  canList(actor: any): boolean { return true }
  canView(actor: any, user: any): boolean { return true }
  canCreate(actor: any): boolean { return true }
  canUpdate(actor: any, user: any): boolean { return true }
  canDelete(actor: any, user: any): boolean { return true }
}
See the Policy guide for the full archetype → method mapping.

Resource — app/resources/{name}_resource.ts

A Resource subclass that controls the JSON shape for API responses. Fields are derived from the schema columns:
// app/resources/user_resource.ts
import { Resource } from '@strav/http'
import type User from '../models/user'

export default class UserResource extends Resource<User> {
  define(user: User) {
    return {
      pid: user.pid,
      username: user.username,
      role: user.role,
      createdAt: user.createdAt,
      updatedAt: user.updatedAt,
    }
  }
}

Field inclusion rules:

  • Included: All regular columns — PKs, FKs, data fields, timestamps
  • Excluded: deleted_at (soft-delete internal) and columns marked sensitive in the schema
See the HTTP guide for full Resource documentation.

Service — app/services/{name}_service.ts

Injectable class wrapping model CRUD and event dispatch. Uses merge() to assign validated data onto model instances:
// app/services/user_service.ts
@inject
export default class UserService {
  async list(page: number, perPage: number) {
    return query(User).paginate(page, perPage)
  }
  async find(id) { return User.find(id) }
  async create(data) {
    const user = new User()
    user.merge(data)
    await user.save()
    await Emitter.emit(UserEvents.CREATED, user)
    return user
  }
  async update(id, data) { / find → merge → save → emit UPDATED / }
  async delete(id) { / find → delete → emit DELETED / }
}
Services are decorated with @inject so the DI container can resolve them as controller dependencies. The list method returns a PaginationResult<T> with data and meta (page, perPage, total, lastPage, from, to).

Archetype-specific behaviour:

  • Dependent archetypes (attribute, component, event, configuration, contribution): listByParent(parentId, page, perPage) uses the query builder with pagination.
  • Event archetype: Only listByParent, find, append (no update/delete).
  • Component: Only listByParent, find, update (no create/delete).
  • Configuration: get(parentId), upsert(parentId, data), reset(parentId).

Controller — app/http/controllers/{name}_controller.ts

Injectable class with the service as a constructor dependency. Each action validates input, calls the service, and returns a response serialized through the Resource:

// app/http/controllers/user_controller.ts
@inject
export default class UserController {
  constructor(protected service: UserService) {}

  async index(ctx: Context) {
    const page = Number(ctx.query.get('page')) || 1
    const perPage = Number(ctx.query.get('perPage')) || 20
    const result = await this.service.list(page, perPage)
    return ctx.json(UserResource.paginate(result))
  }

  async store(ctx: Context) {
    const body = await ctx.body<Record<string, unknown>>()
    const { data: validated, errors } = validate(body, UserRules.store!)
    if (errors) return ctx.json({ errors }, 422)
    const item = await this.service.create(validated)
    return ctx.json(UserResource.make(item), 201)
  }
  // show uses Resource.make(), update uses Resource.make(), destroy returns 204
}
The index action reads ?page=1&perPage=20 from the query string, defaults to page 1 with 20 items per page, and returns a paginated response with both data and meta.

For dependent archetypes, the parent FK is added after validation so it isn't stripped:

  async store(ctx: Context) {
    const body = await ctx.body<Record<string, unknown>>()
    const { data: validated, errors } = validate(body, PostRules.store!)
    if (errors) return ctx.json({ errors }, 422)
    const item = await this.service.create({ ...validated, userPid: ctx.params.parentId! })
    return ctx.json(PostResource.make(item), 201)
  }
Controllers are decorated with @inject for constructor injection. router.resource() automatically resolves them via app.make() with full DI. Actions vary by archetype — event controllers have no update/destroy, component controllers have no store/destroy.

Route file — start/api_routes.ts

generate:api also produces a route file that wires all controllers to the router. Routes are fully derived from archetypes and parent relationships — no manual wiring needed. Controller import paths are computed automatically from the configured routes and controllers directories.
// start/api_routes.ts — Generated by Strav
import { router } from '@strav/http'
import { auth } from '@strav/http'

import {
  UserController,
  PostController,
  CommentController,
  PostSettingController,
} from '../app/http/controllers'

router.group({ prefix: '/api', middleware: [auth()] }, r => {
  r.resource('/users', UserController)

  r.group({ prefix: '/users/:parentId' }, r => {
    r.resource('/posts', PostController)
  })

  r.group({ prefix: '/posts/:parentId' }, r => {
    r.resource('/comments', CommentController)
    r.resource('/settings', PostSettingController).singleton()
  })
})

Routing rules by archetype:

Archetype Routing Notes
entity, reference r.resource() at root Full CRUD
contribution, attribute, component, event r.resource() nested under parent Actions vary by archetype
configuration r.resource().singleton() under parent No :id param — show, update, destroy
association Skipped No routes
Route paths are auto-pluralized: user/users, category/categories. Dependent schemas strip the parent prefix: post_content (parent: post) → /contents.

API routing mode

The route generator reads config/http.ts to determine whether routes use a URL prefix or a subdomain:
// config/http.ts
import { ApiRouting } from '@strav/cli'

export default {
  // ...
  api: {
    routing: ApiRouting.Prefix,   // ApiRouting.Prefix | ApiRouting.Subdomain
    prefix: '/api',                // used when routing = Prefix
    subdomain: 'api',              // used when routing = Subdomain
  },
}
Prefix mode (default) — routes live under /api:
GET /api/users
POST /api/users/:parentId/posts
Subdomain mode — routes live on a subdomain (e.g. api.example.com):
GET api.example.com/users
POST api.example.com/users/:parentId/posts
When using subdomain mode, make sure domain is set correctly in config/http.ts so the router can extract subdomains from the Host header.

Import the generated file from your bootstrap:

// index.ts
await import('./start/api_routes')