Skip to content

CRUD Operations

Every model defined in models.json automatically gets 11 HTTP endpoints – with no additional code. All operations go through the same pipeline: auth dependency -> permission check -> before hooks -> operation -> after hooks.


Operation Method Path
fetch GET /{model}/fetch
search GET /{model}/search
count GET /{model}/count
exists GET /{model}/exists
create POST /{model}/create
update PUT /{model}/update
upsert PUT /{model}/upsert
delete DELETE /{model}/delete
destroy DELETE /{model}/destroy
restore PATCH /{model}/restore
graphql POST /graphql

All paths are relative to the optional kirak_prefix. Example with kirak_prefix="/api/v1": /api/v1/posts/fetch.


Every CRUD and GraphQL operation returns the same shape. The HTTP status line always equals statusCode. See Response Envelope for the full contract.

{
"statusCode": 200,
"status": "success",
"message": "Records fetched successfully",
"data": [...],
"pagination": {
"limit": 20,
"page": 1,
"offset": 0,
"total": 156,
"total_pages": 8,
"has_more": true
}
}

pagination is only present when page or offset is passed to fetch or search. There is no token key.

Error envelope (HTTP status == statusCode):

{
"statusCode": 403,
"status": "error",
"error": "PERMISSION_DENIED",
"message": "Role 'user' is not authorized to destroy 'posts'",
"data": null
}

error is the stable machine code – branch on it, not on message. details is added only when the failure carries structured context.


All read operations (fetch, search, count, exists) accept a query object. Keys follow the field__operator pattern:

Suffix SQL operator Example
(none) = {"status": "active"}
__gte >= {"age__gte": 18}
__gt > {"price__gt": 100}
__lte <= {"score__lte": 99}
__lt < {"stock__lt": 10}
__ne != {"status__ne": "deleted"}
__like LIKE {"name__like": "john"} – auto-wrapped in %...%
__ilike case-insensitive LIKE {"name__ilike": "john"} – MySQL: LOWER(field) LIKE LOWER(...), PostgreSQL: native ILIKE
__in IN (...) {"id__in": [1,2,3]} or "1,2,3"
__not_in NOT IN (...) {"status__not_in": ["draft","archived"]}
__between BETWEEN ... AND ... {"price__between": [10, 50]}
__is_null IS NULL {"deleted_at__is_null": true} – value ignored
__is_not_null IS NOT NULL {"photo__is_not_null": true}

All filters are combined with AND. There is no OR at the query level (use GraphQL for complex logic). All values are parameterized – never interpolated into SQL.

On created_at, updated_at, deleted_at and timestamp / datetime / date fields, a string value must be ISO-8601: "2026-09-01", "2026-09-01T12:00:00", or with Z / an offset (converted to UTC, which these columns hold). A date field takes a date only. Anything else ("today", "2026-13-01") is a 400 VALIDATION_ERROR, on MySQL and PostgreSQL alike. Python callers can also pass date / datetime objects. Create and update parse these fields the same way.


Query records with filters, field selection, ordering, and pagination.

GET /{model}/fetch

Query parameters:

Parameter Type Default Description
query object {} Filter conditions using field__operator keys
limit int 10 Records to return. Capped at model’s max_limit (default 1000).
page int – Page number (1-based). Triggers pagination metadata.
offset int – Row offset. Alternative to page.
order_by string model default_order Field to sort by. Comma-separated for multiple: "name,created_at". Prefix with - for DESC: "-created_at".
order string ASC Default direction for order_by fields without a prefix.
select_fields string/array all readable fields Comma-separated list or JSON array of fields to include in response. Requesting an unauthorized field returns 403.

Examples:

Terminal window
# Basic fetch with filters
GET /posts/fetch?status=published&user_id=42
# Pagination
GET /posts/fetch?page=2&limit=20
# Date range + ordering
GET /orders/fetch?created_at__gte=2025-01-01&order_by=-created_at&limit=50
# Select specific fields
GET /posts/fetch?select_fields=id,title,created_at

Via Python facade:

await kirak.fetch("posts", {
"status": "published",
"user_id": 42,
"limit": 20,
"page": 1,
"order_by": "-created_at",
"select_fields": ["id", "title", "created_at"],
})

Full-text search across all fields marked "searchable": true in the model schema.

GET /{model}/search

Parameter Type Description
search_term string Text to search for. Applied as LIKE %term% across all searchable fields.
query object Additional filters combined with the search term.
limit int Max records. Default 10.
page int Page number for pagination.
order_by string Sort field.
order string Sort direction.
Terminal window
GET /posts/search?search_term=kirak+runtime&limit=5

Models with no searchable: true fields will return zero results from search.


Count records matching filters without fetching them.

GET /{model}/count

Terminal window
GET /orders/count?status=pending
# -> {"data": {"count": 42}}

Pass group_by to get counts grouped by a field instead of one total:

Terminal window
GET /orders/count?group_by=status
# -> {"data": [{"status": "pending", "value": 12}, {"status": "shipped", "value": 30}, ...]}

Grouped results are capped at 1000 groups (GROUP BY ... LIMIT 1000).


Boolean existence check. Returns a single boolean result.

GET /{model}/exists

Terminal window
GET /users/exists?email=alice@example.com
# -> {"data": {"exists": true}}

Insert one record. Returns the new record’s id.

POST /{model}/create

{
"data": {
"title": "Hello Kirak",
"content": "My first post",
"user_id": 1
}
}

Response:

{ "statusCode": 200, "status": "success", "message": "posts created successfully", "data": { "id": 42 } }

The message is always "{model_name} created successfully" – branch on status/error, not on this string.

Ownership injection: If the model’s access.create rule includes a condition like "user_id = {user_id}", Kirak automatically injects user_id from the JWT claims into the INSERT – the caller does not need to (and cannot override) this field.

Pass data as a list to insert multiple records in one call:

{
"data": [
{ "title": "Post 1", "user_id": 1 },
{ "title": "Post 2", "user_id": 1 }
],
"chunk_size": 500,
"batch_size": 200
}
Parameter Description
chunk_size Records validated + inserted per memory cycle. Default: all at once.
batch_size Records per single INSERT statement. Auto-sized for PostgreSQL’s parameter limit (32767 total).

Bulk create runs inside a single transaction. One failure rolls back all chunks. Complex RLS conditions (OR, AND) that cannot be expressed as a field injection reject the operation – use single-record create instead.

Response:

{
"statusCode": 200,
"status": "success",
"message": "Bulk create completed",
"data": {
"inserted_count": 498,
"inserted_ids": [101, 102, ...],
"failed_count": 2,
"failed_records": [{ "record": {...}, "error": "Duplicate value for unique field 'email'" }],
"total_attempted": 500
}
}

Update records matching a filter.

PUT /{model}/update

{
"where": { "id": 42 },
"data": { "title": "Updated Title", "status": "published" }
}

If the model has a row-level condition for update (e.g. "user_id = {user_id}"), it is automatically appended to the WHERE clause – the user can only update their own records.

where is required. A non-bulk update with no where returns 400 VALIDATION_ERROR (Update operation requires 'where' filters for safety). To update every row, pass an explicit always-true filter.

Pass data as a list to update multiple records, each with its own where condition:

{
"data": [
{ "where": { "id": 1 }, "data": { "status": "active" } },
{ "where": { "id": 2 }, "data": { "status": "inactive" } }
]
}

Insert a record or update it on a unique field conflict.

PUT /{model}/upsert

{
"data": {
"email": "alice@example.com",
"name": "Alice Smith",
"role": "admin"
}
}

The conflict is detected on fields marked "unique": true in the model schema. If a conflict is found, all non-unique fields in data are updated. Unlike create/update/restore, upsert does not automatically touch updated_at on conflict – it is only set if you include it explicitly in data.


Soft-delete records (sets deleted_at). Only works on models with "soft_delete": true.

DELETE /{model}/delete

Terminal window
DELETE /posts/delete?id=42
# or with a JSON body:
# { "query": { "id": 42 } }

Soft-deleted records become invisible to all fetch/search/count/exists queries.

Pass a list of ids to soft-delete multiple records in one call:

{
"ids": [1, 2, 3, 4, 5],
"chunk_size": 500
}

chunk_size controls how many rows are processed per batch (default: bulk_chunk_size in kirak.json, 500). See Performance for tuning guidance.


Permanent hard delete. Removes rows from the database entirely.

DELETE /{model}/destroy

Terminal window
DELETE /posts/destroy?id=42

Same shape as bulk delete – pass ids and optionally chunk_size:

{ "ids": [1, 2, 3], "chunk_size": 500 }

Un-soft-delete a record. Clears deleted_at, making the record visible again.

PATCH /{model}/restore

Terminal window
# A bare id only narrows `where` -- you must also pass restore data explicitly:
PATCH /posts/restore?id=42&data.deleted_at=null
# or with a JSON body:
# { "where": { "id": 42 }, "data": { "deleted_at": null } }

data is required and must contain at least one restore-related field (deleted_at, status, active, enabled, archived, restored_at, or restored_by) – an empty data returns 400 VALIDATION_ERROR (No restore data provided). Only applicable to models with "soft_delete": true.


A single shared GraphQL endpoint supports queries and mutations across all models.

POST /graphql

{
"query": "{ posts(status: \"published\", limit: 10) { id title created_at } }"
}

GraphQL enforces the same access-control rules as REST and returns the same response envelope. It does not run per-model CRUD hooks, and a multi-root mutation runs in one transaction. See GraphQL.


All 11 operations are available directly on the kirak instance for use in hooks and background tasks:

# From hooks or background code
await kirak.fetch("products", {"is_active": True, "limit": 100})
await kirak.create("orders", {"data": {"user_id": 1, "total": 99.99}})
await kirak.update("orders", {"where": {"id": 5}, "data": {"status": "shipped"}})
await kirak.delete("orders", {"id": 5})
await kirak.destroy("audit_logs", {"created_at__lt": "2024-01-01"})
await kirak.count("orders", {"status": "pending"})
await kirak.exists("users", {"email": "alice@example.com"})
await kirak.search("products", {"search_term": "bluetooth", "limit": 10})
await kirak.upsert("settings", {"data": {"key": "theme", "value": "dark"}})
await kirak.restore("posts", {"where": {"id": 42}})

Direct calls run the same pipeline as HTTP requests: hooks, validation, and the model’s access block and field-level rules, for the identity the call runs as.

Where the call is made Runs as
In a hook fired by one of Kirak’s own routes (CRUD, GraphQL, auth, payments, scheduler, AI) The caller of that request, resolved from its token or API key
After set_user_context(user) That user
In your own FastAPI route Guest, unless the route sets the caller (below) – Kirak’s request context is set only by its own routes
Anywhere else: scheduler jobs, cron tasks, startup code, scripts Guest – nothing sets an identity for them

In your own route, look the caller up and set it, so the model’s access rules apply to them:

from fastapi import Request
from kirak.core.context import set_user_context, reset_user_context
@app.get("/my-orders/pending")
async def my_pending_orders(request: Request):
kirak = request.app.state.kirak
caller = (await kirak.auth.get_current_user({"request": request})).get("data") or {}
token = set_user_context(caller)
try:
return await kirak.fetch("orders", {"status": "pending"})
finally:
reset_user_context(token)

Outside a request, set the identity yourself. To act for a user, set that user so their access rules apply:

from kirak.core.context import set_user_context, reset_user_context
token = set_user_context({"role": "user", "user_id": 42, "token": None})
try:
await kirak.fetch("orders", {"status": "pending"}) # only user 42's orders, per the access block
finally:
reset_user_context(token)

For work no user owns (a nightly report, a cleanup), set the system identity, {"role": "system", "user_id": None, "token": None}. system is not a superuser for your models: an operation is allowed only where the model’s access block lists {"role": "system"}, row conditions on that rule apply, and only field-level security is bypassed (see Access Control). Grant it only the operations the background code needs.

Two details:

  • While the hooks of a call made under set_user_context(...) run, the context is cleared: get_user_context() returns None in those hooks, and a direct call made from one runs as guest. Set the context again inside the hook if it needs to call Kirak.
  • Prefer these operations (and kirak.graphql() for joins and aggregates) over kirak.execute_query(). Raw SQL skips the access block, hooks and field rules entirely; everything a model can hold is reachable through them.