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.
Endpoint Convention
Section titled “Endpoint Convention”| 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.
Response Envelope
Section titled “Response Envelope”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.
Filter Operators
Section titled “Filter Operators”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:
# Basic fetch with filtersGET /posts/fetch?status=published&user_id=42
# PaginationGET /posts/fetch?page=2&limit=20
# Date range + orderingGET /orders/fetch?created_at__gte=2025-01-01&order_by=-created_at&limit=50
# Select specific fieldsGET /posts/fetch?select_fields=id,title,created_atVia 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"],})search
Section titled “search”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. |
GET /posts/search?search_term=kirak+runtime&limit=5Models with no searchable: true fields will return zero results from search.
Count records matching filters without fetching them.
GET /{model}/count
GET /orders/count?status=pending# -> {"data": {"count": 42}}Pass group_by to get counts grouped by a field instead of one total:
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).
exists
Section titled “exists”Boolean existence check. Returns a single boolean result.
GET /{model}/exists
GET /users/exists?email=alice@example.com# -> {"data": {"exists": true}}create
Section titled “create”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.
Bulk Create
Section titled “Bulk Create”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
Section titled “update”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.
Bulk Update
Section titled “Bulk Update”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" } } ]}upsert
Section titled “upsert”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.
delete
Section titled “delete”Soft-delete records (sets deleted_at). Only works on models with "soft_delete": true.
DELETE /{model}/delete
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.
Bulk Delete
Section titled “Bulk Delete”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.
destroy
Section titled “destroy”Permanent hard delete. Removes rows from the database entirely.
DELETE /{model}/destroy
DELETE /posts/destroy?id=42Bulk Destroy
Section titled “Bulk Destroy”Same shape as bulk delete – pass ids and optionally chunk_size:
{ "ids": [1, 2, 3], "chunk_size": 500 }restore
Section titled “restore”Un-soft-delete a record. Clears deleted_at, making the record visible again.
PATCH /{model}/restore
# 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.
graphql
Section titled “graphql”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.
Using the Python Facade
Section titled “Using the Python Facade”All 11 operations are available directly on the kirak instance for use in hooks and background tasks:
# From hooks or background codeawait 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.
Who a direct call runs as
Section titled “Who a direct 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 Requestfrom 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 blockfinally: 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()returnsNonein 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) overkirak.execute_query(). Raw SQL skips theaccessblock, hooks and field rules entirely; everything a model can hold is reachable through them.