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.
File Format
Section titled “File Format”Models live in one or more JSON files pointed at by models_path:
Single file:
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.
models/+-- posts.json+-- products.json+-- orders.jsonModel Structure
Section titled “Model Structure”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.
Reserved Fields
Section titled “Reserved Fields”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 |
Field Types
Section titled “Field Types”| 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 |
Field Properties
Section titled “Field Properties”| 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. |
Model Properties
Section titled “Model Properties”table / table_name
Section titled “table / table_name”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" }soft_delete
Section titled “soft_delete”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 }id_type
Section titled “id_type”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.
max_limit
Section titled “max_limit”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
Section titled “default_order”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.
internal
Section titled “internal”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 }rate_limit
Section titled “rate_limit”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. |
Relationships
Section titled “Relationships”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.
Access Block
Section titled “Access Block”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
accessblock present -> deny by default. Operation not listed = 403 for all roles. - Each rule requires a
rolestring and an optionalcondition. "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.
Built-in Runtime Tables
Section titled “Built-in Runtime Tables”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.
Complete Model Example
Section titled “Complete Model Example”{ "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" } }}