Skip to content

Defining Models

Models are the heart of Kirak. Every model is a JSON object that defines a database table, its fields, its access policy, and its runtime behaviour. Kirak reads all model files at startup, validates them, and generates routes, migrations, and permission checks automatically.


Models live in one or more JSON files pointed at by models_path:

Single file:

Terminal window
create_kirak_app(models_path="./models/models.json")

Directory (recommended): every *.json file in the directory is merged at startup. A ConfigurationError is raised on name collision.

Terminal window
models/
+-- posts.json
+-- products.json
+-- orders.json

Every model has two top-level concerns that are intentionally kept separate:

Key Purpose
schema Describes the data: field types, constraints, defaults, search behaviour
access Describes who can read or modify that data and under what conditions

A complete model:

{
"posts": {
"table": "posts",
"soft_delete": true,
"schema": {
"title": { "type": "string", "required": true, "max_length": 255 },
"content": { "type": "text" },
"user_id": { "type": "integer", "required": true }
},
"access": {
"fetch": [{ "role": "user" }, { "role": "admin" }],
"create": [{ "role": "user" }, { "role": "admin" }],
"update": [
{ "role": "user", "condition": "user_id = {user_id}" },
{ "role": "admin" }
],
"delete": [
{ "role": "user", "condition": "user_id = {user_id}" },
{ "role": "admin" }
],
"destroy": [{ "role": "admin" }]
}
}
}

schema is about your data structure. access is about your security policy. Changing one does not require touching the other.


These four fields are managed automatically – do not put them in your schema block:

Field Type When set
id auto-increment integer (or UUID – see below) On INSERT
created_at timestamp On INSERT
updated_at timestamp On INSERT and every UPDATE
deleted_at timestamp On soft-delete; cleared on restore

Type SQL equivalent Notes
string VARCHAR(255) Default length is 255; override with max_length
text TEXT No max_length limit
integer INT
float FLOAT
decimal DECIMAL(precision, scale) Use precision and scale to control range
boolean TINYINT(1)
timestamp TIMESTAMP
datetime DATETIME
date DATE
uuid CHAR(36) Stored as a 36-character hex string
json JSON Python dict/list, serialized transparently
email VARCHAR(255) Validated as a valid email address at runtime
password VARCHAR(255) Hashed with bcrypt on write; never returned in reads
file VARCHAR(500) Stores a file path or URL string
image VARCHAR(500) Stores an image path or URL string
multiselect JSON Stored as a JSON array; serialized/deserialized transparently

Property Type Description
type string Required. One of the types above.
required bool Reject create/update if this field is absent or null. Default false.
unique bool Check uniqueness before INSERT/UPDATE. Default false.
searchable bool Include this field in full-text search queries. Default false.
sortable bool Allow this field in order_by. Default true – set false to exclude a field from ORDER BY.
label string Human-readable name used in validation error messages instead of the raw field key.
default any Default value applied when field is absent from create payload.
enum array Allowed values. Runtime validation rejects anything not in the list.
values array Informational alias for enum (same behaviour, kept for compatibility).
max_length int Maximum string length. Applied to string and email fields.
precision int Total digits for decimal.
scale int Decimal places for decimal.
primary_key bool Mark field as primary key (usually only needed for custom schemas).
auto_increment bool Add AUTO_INCREMENT / SERIAL to a custom integer PK.
read_roles array Deprecated. Roles allowed to read this field. Deprecated – use access.fields.
write_roles array Deprecated. Roles allowed to write this field. Deprecated – use access.fields.
create_roles array Deprecated. Roles allowed to set this field on create only. Deprecated – use access.fields.
update_roles array Deprecated. Roles allowed to set this field on update only. Deprecated – use access.fields.

The database table name. Must be a valid SQL identifier (^[a-zA-Z_][a-zA-Z0-9_]*$). Kirak prevents table-name injection through schema validation.

{ "table": "blog_posts" }

When true, the delete operation sets deleted_at instead of removing the row. All fetch, search, count, and exists queries automatically exclude soft-deleted records (WHERE deleted_at IS NULL). Users cannot bypass this filter – any deleted_at filter supplied by callers is stripped before query execution.

{ "soft_delete": true }

Override the default auto-increment integer primary key with a UUID:

{ "id_type": "uuid" }

Kirak generates the UUID in Python (not in the database) so it works identically on MySQL and PostgreSQL.

Cap the maximum number of records a single fetch call may return:

{ "max_limit": 500 }

Default is 1000. Protects against accidental large result sets.

Default ORDER BY clause when order_by is not passed by the caller:

{ "default_order": "created_at DESC" }

The URL segment used for this model’s routes. Defaults to the model name. Useful when your model name and URL should differ:

{
"posts": {
"table": "blog_posts",
"slug": "articles"
}
}

Routes will be mounted at /articles/fetch, /articles/create, etc.

Set to true to suppress auto-generated CRUD routes. The model still gets a database table. Used for built-in tables like auth_tokens that must exist but must not be publicly exposed.

{ "internal": true }

Per-model rate limiting. See Rate Limiting for the full reference.

{
"rate_limit": {
"max_requests": 30,
"window_seconds": 60,
"per": "user"
}
}
Key Values Description
enabled bool false disables rate limiting for this model. Absent or true keeps it active.
max_requests int Request budget per window
window_seconds int Rolling window in seconds
per ip / user / model Scope of the counter

Omitting rate_limit or setting it to {} both inherit the global default from kirak.json.

Per-model Redis query caching for fetch/search/count/exists (REST, script mode, and GraphQL field queries). Requires KIRAK_REDIS_URL and kirak.json’s cache.enabled – see Redis Integration for the full reference, including the generational key scheme and how mutations invalidate it.

{
"cache": {
"enabled": true,
"ttl_seconds": 60
}
}
Key Values Description
enabled bool Absent -> inherits kirak.json’s cache.enabled default. Use false to explicitly opt out.
ttl_seconds int How long a cached response is served before re-querying the DB. Absent -> inherits kirak.json’s cache.default_ttl_seconds.

Foreign-key relationships are declared directly on the field in schema. Set foreign_key: true and add a relationship object with the target model name and the field on that model to join against:

{
"schema": {
"user_id": {
"type": "integer",
"required": true,
"foreign_key": true,
"relationship": { "model": "users", "foreign_key": "id" }
}
}
}

With this declaration, the GraphQL layer resolves user_id as a joinable field – selecting it with a sub-selection produces a LEFT JOIN to the users table. See GraphQL Relationships for query syntax and nested join support.


The access block defines which roles may call each operation and optionally adds row-level security conditions.

{
"access": {
"fetch": [{ "role": "user" }, { "role": "admin" }],
"search": [{ "role": "user" }, { "role": "admin" }],
"count": [{ "role": "admin" }],
"exists": [{ "role": "admin" }],
"create": [{ "role": "user" }],
"update": [{ "role": "user", "condition": "user_id = {user_id}" }],
"upsert": [{ "role": "admin" }],
"delete": [{ "role": "user", "condition": "user_id = {user_id}" }],
"destroy": [{ "role": "admin" }],
"restore": [{ "role": "admin" }]
}
}

Rules:

  • If access block present -> deny by default. Operation not listed = 403 for all roles.
  • Each rule requires a role string and an optional condition.
  • "role": "*" matches any user, including unauthenticated requests (wildcard – enables public access).
  • Exact role match takes precedence over wildcard.

Conditions are SQL WHERE fragments with {variable} placeholders resolved from the current user’s JWT claims:

{ "role": "user", "condition": "user_id = {user_id}" }

Allowed operators in conditions: =, !=, <>, <, >, <=, >=, IS NULL, IS NOT NULL, LIKE. Multiple clauses joined by AND or OR. Conditions are validated at startup – invalid syntax raises ConfigurationError. Values are never embedded directly into SQL; always parameterized.

Field-level security:

{
"access": {
"fields": {
"salary": {
"read": ["admin", "hr"],
"write": ["admin"]
},
"ssn": {
"read": [],
"write": []
}
}
}
}
  • Field listed with roles -> only those roles can access it.
  • Field listed with empty array -> nobody can access it.
  • Field absent from access.fields -> no restriction.

See Access Control for the complete reference.


The following tables are built into Kirak and are always included in migrations. You do not define them:

Table Purpose
users User accounts
auth_tokens Active refresh tokens
auth_social Social login linked accounts
auth_mfa MFA TOTP secrets
auth_otp Phone OTP codes
auth_token_blacklist Revoked JWT JTIs
kirak_rate_limits Per-key rate limit counters (used by both per-model CRUD limits and the hardcoded auth endpoint limits)

All are marked internal: true and protected by "role": "system" access rules. kirak_rate_limits is sourced from a separate core_models.json (runtime-wide tables), not auth_models.json – a distinction that only matters if you’re working on Kirak’s own internals; both merge into every project’s schema the same way.

Built-in tables cannot be overridden or extended. Kirak loads them after your models, at runtime and in kirak db makemigrations, so a model of yours with the same name (a users model in models/, for example) is silently replaced by the built-in one: its fields, id_type and access rules are ignored. kirak validate warns about it (model_name_reserved). Keep data of your own in a model with another name, linked to users by a user_id field.


{
"products": {
"table": "products",
"soft_delete": true,
"max_limit": 200,
"default_order": "created_at DESC",
"schema": {
"name": { "type": "string", "required": true, "max_length": 120, "searchable": true },
"description": { "type": "text", "searchable": true },
"price": { "type": "decimal", "required": true, "precision": 10, "scale": 2 },
"category": { "type": "string", "enum": ["electronics", "clothing", "food"] },
"tags": { "type": "multiselect" },
"images": { "type": "json" },
"stock": { "type": "integer", "default": 0 },
"is_active": { "type": "boolean", "default": true },
"seller_id": { "type": "integer", "required": true }
},
"access": {
"fetch": [{ "role": "*" }],
"search": [{ "role": "*" }],
"count": [{ "role": "*" }],
"create": [{ "role": "seller", "condition": "seller_id = {user_id}" }, { "role": "admin" }],
"update": [{ "role": "seller", "condition": "seller_id = {user_id}" }, { "role": "admin" }],
"delete": [{ "role": "seller", "condition": "seller_id = {user_id}" }, { "role": "admin" }],
"destroy": [{ "role": "admin" }],
"restore": [{ "role": "admin" }]
},
"rate_limit": {
"max_requests": 100,
"window_seconds": 60,
"per": "ip"
}
}
}