Build with BQL
BlitzStore is a polymorphic graph database. Define your data as , connect them with , and query everything, including nested relationships, in a single JSON request.
Connect
Set up HTTP access, choose the right token family, and keep app-session auth separate from dataserver auth.
Authenticate
The BlitzStore data server accepts Authorization: Bearer <token> only. There is no API-Key header. Two token families use that same transport:
bzt_*: scoped customer grant tokens. Mint these through the BlitzGraph control plane and use them for data-plane calls (/query,/mutate,/admin).bzi_*: internal control-plane keys. Only these are allowed on/internal/system/*; abzt_*on those routes returns 403 even if it authenticates successfully.
curl -X POST https://api.blitzgraph.com/query \ -H "Content-Type: application/json" \ -H "Authorization: Bearer bzt_your.token-here" \ -d '{"query":{"$kinds":"User","$fields":"*","$limit":10}}'
Runtime rotation of bzi_* keys happens through /internal/system/keys/upsert; new grant tokens are projected through /internal/system/grants/upsert.
Better Auth bearer access tokens do not authenticate directly against the dataserver. They authenticate human/agent sessions on BlitzGraph app routes such as POST /agents/build-now, which then mint or rotate a scoped bzt_* for the logged-in user. The dataserver only ever sees the minted bzt_*.
Platform
Two platform surfaces matter in practice: storage scopes that isolate data, and a workspace for operating on those scopes.
Namespaces & Subspaces
A is the top-level isolation boundary inside a database. Each has its own schema, data, and indexes. A provides logical isolation within a namespace: same schema, separate data. Free-tier users get one namespace and can create multiple subspaces (e.g. main, archive, drafts).
// Query a specific subspace { "$kinds": "Note", "$subspace": "archive", "$fields": "*" } // Mutate into a specific subspace { "$setKinds": ["Note"], "content": "temp", "$subspace": "drafts" } // Omit $subspace → defaults to "main" { "$kinds": "Note", "$fields": "*" }
// POST /admin: create a subspace { "$resource": "subspace", "$op": "create", "$rid": "ss:archive" } // POST /admin: list subspaces { "$resource": "subspace", "$op": "query" }
Data is fully isolated between subspaces. A query on "main" never sees data from "archive". Each subspace can have its own schema, defined via with .defaultSubspace("name").
BlitzStudio
BlitzStudio is the built-in visual interface for managing your data. It connects to any BlitzStore instance and gives you a full workspace with schema editing, data browsing, and a live query console.
Schema
Define your data model with , fields, , validations, computed fields, and mutation hooks. Import your once and the same model drives /query, /mutate, and BlitzStudio.
Database Structure
BlitzStore separates (your units and connections) from ( and ).
POST /queryandPOST /mutateoperate on dataPOST /definition/import,/definition/query,/definition/mutateoperate on definitions (see )POST /adminuses raw admin JSON, not a{ body, opts }envelopePOST /data/importreturns JSON by default; addAccept: text/event-streamfor SSE progress
Kinds & Fields
- A is a schema definition (like a class or table)
- A is a stored instance of one or more kinds
- Kinds have three field types:
- holds values, validations, and compute behavior
- defines connection points (see )
- is a shortcut to traverse from the other side
- Mutation behavior that spans multiple fields belongs on kind-level
{
"kinds": {
"User": {
"dataFields": {
"name": { "valueType": "TEXT" },
"email": { "valueType": "EMAIL", "unique": true },
"age": { "valueType": "INTEGER" }
}
},
"Article": {
"dataFields": {
"title": { "valueType": "TEXT", "required": true, "fts": true },
"body": { "valueType": "TEXT", "fts": true },
"status": { "valueType": "TEXT" }
}
}
}
}A FILE field stores a blob plus metadata. The TS client auto-detects File / Blob values in mutate() and switches to multipart; the JSON body carries a { "#file": "<key>" } marker that the server matches to the named part. On read, the value materialises as FileValue with filename / mime / size / url / thumbnail_url.
// multipart body has parts: "mutation" (JSON) + named file parts { "$setKinds": ["Document"], "title": "Annual report", "attachment": { "#file": "file_0" } } // → server stores blob, returns FileValue with signed url
Changing a field's valueType between INTEGER, DECIMAL, FLOAT, and PERCENTAGEsucceeds. The schema mutation returns immediately; a background task rewrites stored rows using the field's numericPolicy — rejectOnLoss (default), round, truncate, or allowPrecisionLoss. NaN / ±Inf always reject.
Polymorphism & Inheritance
- Polymorphism. A can hold several at once, and evolve them over time. A User can gain or lose Admin without being re-created.
- Inheritance. A kind can declare a
parent; it inherits that parent's fields and roles, and$kinds: "Parent"queries match all descendants by default.
// Create a unit with two kinds at once { "$setKinds": ["User", "Admin"], "name": "Alice" } // Add a kind to an existing unit { "$id": "user_abc", "$setKinds": [{ "$op": "add", "$kinds": ["Moderator"] }] } // Remove a kind (at least one must remain) { "$id": "user_abc", "$setKinds": [{ "$op": "remove", "$kinds": ["Admin"] }] }
{
"kinds": {
"User": { "dataFields": { "email": { "valueType": "EMAIL" } } },
"Admin": { "parent": "User", "dataFields": { "level": { "valueType": "INTEGER" } } },
"SuperAdmin": { "parent": "Admin" }
}
}
// Matches User + Admin + SuperAdmin
{ "$kinds": "User" }
// Exact match, no descendants
{ "$kinds": "User", "$descendants": false }Validations & Computed Fields
Field rules live next to each . Use built-in validators for type and range checks, custom $js validators for business rules, and computed fields when a value should be derived instead of stored. Computed fields can also read meta values like $id or $created_at.
{
"kinds": {
"User": {
"dataFields": {
"age": {
"valueType": "INTEGER",
"validations": { "required": true, "min": 0, "max": 150 }
},
"email": { "valueType": "EMAIL" },
"backupEmail": {
"valueType": "TEXT",
"validations": {
"custom": [{
"on": ["create", "update"],
"$js": "$value.includes('@')",
"message": "must be a valid email",
"severity": "error"
}]
}
}
}
}
}
}{
"kinds": {
"User": {
"idField": "email",
"dataFields": {
"firstName": { "valueType": "TEXT" },
"familyName": { "valueType": "TEXT" },
"email": { "valueType": "EMAIL", "unique": true },
"fullName": {
"valueType": "FLEX",
"computeType": "computed",
"$js": "firstName + ' ' + familyName"
},
"displayId": {
"valueType": "FLEX",
"computeType": "computed",
"$js": "$id"
},
"role": {
"valueType": "TEXT",
"computeType": "editable",
"$js": "'user'"
}
}
}
}
}- Content types like
EMAILalready run system validation - Custom validators use
$valueand can targetcreateand/orupdate severity: "error"blocks the mutation
computeType: "computed"runs on every query and rejects writescomputeType: "editable"+$jsacts as a create-time default that can still be overwritten later- Computed fields can reference
$id,$iid,$version,$created_at,$updated_at, and$kinds
Hooks
Hooks live at the top of the schema in a schema.hooks map keyed by name and target Units via { ops, $kinds, $filter }. Each hook declares its category through type (unit.validate, unit.transform, or unit.effect) and runs inside the mutation pipeline when its target matches the Unit draft. Use them when a write needs to derive fields, validate a final draft across multiple fields, or fire post-commit side effects.
{
"kinds": {
"BlogPost": {
"dataFields": {
"title": { "valueType": "TEXT" },
"slug": { "valueType": "TEXT" },
"status": { "valueType": "TEXT" },
"word_count": { "valueType": "INTEGER" }
}
}
},
// Hooks live as a sibling of kinds at the schema level (refactored 2026-05-09).
// Each hook declares its category via "type" and applicability via "target".
"hooks": {
// transform: derive or normalize fields before validation
"BlogPost.generateSlug": {
"type": "unit.transform",
"target": {
"ops": ["create", "update"],
"$kinds": { "$any": ["BlogPost"] }
},
"when": { "$js": "$this.title" },
"$js": "({ slug: $this.title.toLowerCase().replace(/\s+/g, '-') })"
},
"BlogPost.defaultStatus": {
"type": "unit.transform",
"target": {
"ops": ["create"],
"$kinds": { "$any": ["BlogPost"] }
},
"when": { "$js": "!$this.status" },
"$js": "({ status: 'draft' })"
},
// validate: reject an invalid final draft state
"BlogPost.requireTitle": {
"type": "unit.validate",
"target": {
"ops": ["create", "update"],
"$kinds": { "$any": ["BlogPost"] }
},
"$js": "$this.title && $this.title.length > 0",
"message": "Title is required",
"severity": "error"
},
// effect: post-commit side effects (no pre-effect on schema.hooks)
"BlogPost.postLog": {
"type": "unit.effect",
"target": {
"ops": ["create"],
"$kinds": { "$any": ["BlogPost"] }
},
"$js": "true"
}
}
}{
"kinds": {
"Person": {
"dataFields": {
"name": { "valueType": "TEXT" }
}
}
},
"hooks": {
"Person.upcase": {
"type": "unit.transform",
"target": {
"ops": ["create", "update"],
"$kinds": { "$any": ["Person"] }
},
"remote": "upcase_name"
}
}
}- Order is transform → validate → effect
- Validate hooks see the final transaction state and can also inspect
$input - Hooks defined on ancestor kinds are inherited by descendants
- Transform hooks can return patches inline or with an explicit
return
- Hooks mutate
$this; cross-unit cascades are not supported yet - Pre-effects block the mutation, post-effects keep the write and surface failures separately
- Transform hooks on
linkare not implemented yet - Non-converging transform loops fail with max-depth protection
Relations
- A lives on the relation side and defines who can connect and how many ()
- A lives on the player side and names the relation kind plus the role it plays there
- are kinds too, so they can carry their own data fields and be queried directly
target: "relation"returns the relation units;target: "role"andtargetRolesproject through to the opposite endpoint(s)
There are two shapes to remember. For a simple dependency like book → author, put the on one side and keep the reverse as a . That is a direct relation, not a tunnel. Giulietta's Rule is just a recommendation for choosing the owner. When the connection needs its own fields, create an intermediate relation kind and expose one raw link plus one projected tunnel on each side if that helps query ergonomics.
// Direct relation, no tunnel. // One valid ownership choice: Book owns the roleField, Author keeps the reverse linkField. { "kinds": { "Author": { "dataFields": { "name": { "valueType": "TEXT" }, "slug": { "valueType": "TEXT", "unique": true } }, "linkFields": { "books": { "relation": "Book", "plays": "author" } } }, "Book": { "dataFields": { "title": { "valueType": "TEXT" }, "publishedYear": { "valueType": "INTEGER" } }, "roleFields": { "author": { "playedBy": ["Author"], "cardinality": "ONE", "required": true } } } } }
{
"kinds": {
"Company": {
"dataFields": {
"name": { "valueType": "TEXT" }
},
"linkFields": {
"memberships": {
"relation": "Membership",
"plays": "company",
"target": "relation"
},
"employees": {
"relation": "Membership",
"plays": "company",
"target": "role",
"targetRoles": ["employee"]
}
}
},
"Employee": {
"dataFields": {
"name": { "valueType": "TEXT" },
"email": { "valueType": "EMAIL" }
},
"linkFields": {
"memberships": {
"relation": "Membership",
"plays": "employee",
"target": "relation"
},
"companies": {
"relation": "Membership",
"plays": "employee",
"target": "role",
"targetRoles": ["company"]
}
}
},
"Membership": {
"dataFields": {
"title": { "valueType": "TEXT" },
"startDate": { "valueType": "DATE" }
},
"roleFields": {
"company": { "playedBy": ["Company"], "cardinality": "ONE" },
"employee": { "playedBy": ["Employee"], "cardinality": "ONE" }
}
}
}
}{
"$kinds": "Company",
"$fields": [
"name",
{
"$expand": "memberships",
"$fields": [
"title",
"startDate",
{ "$expand": "employee", "$fields": ["name", "email"] }
]
}
]
}required for exactly 1.min and max bounds.INTERVAL value type.A role can be played by several kinds via playedBy: ["A", "B"]. Nested creates must specify $setKinds so the engine knows which kind to create; when playedBy has exactly one kind, it is inferred.
{
"kinds": {
"Employee": {
"dataFields": { "name": { "valueType": "TEXT" } }
},
"Agency": {
"dataFields": { "name": { "valueType": "TEXT" } }
},
"Project": {
"dataFields": { "name": { "valueType": "TEXT" } },
"linkFields": {
"assignments": {
"relation": "Assignment",
"plays": "project",
"target": "relation"
},
"assignees": {
"relation": "Assignment",
"plays": "project",
"target": "role",
"targetRoles": ["assignee"]
}
}
},
"Assignment": {
"roleFields": {
"project": { "playedBy": ["Project"], "cardinality": "ONE" },
"assignee": { "playedBy": ["Employee", "Agency"], "cardinality": "ONE" }
}
}
}
}
// Nested create: $setKinds disambiguates the polymorphic assignee
{
"$setKinds": ["Assignment"],
"project": "project_123",
"assignee": { "$setKinds": ["Agency"], "name": "Acme Staffing" }
}symmetric: true on a relation kind collapses A↔B and B↔A to a single edge — the duplicate is rejected with SYMMETRIC_RELATION_EXISTS. By default each role rejects the same player appearing in two distinct roles of the same relation unit (SELF_RELATION_FORBIDDEN); opt in per role with allowSelfRelation: true.
{
"kinds": {
"Friendship": {
"symmetric": true,
"roleFields": {
"friends": { "playedBy": ["User"], "cardinality": "MANY" }
}
},
"Reference": {
"roleFields": {
"from": { "playedBy": ["Document"], "allowSelfRelation": true },
"to": { "playedBy": ["Document"], "allowSelfRelation": true }
}
}
}
}Schema Operations
Four endpoints manage at runtime: import, export, query, and mutate. Import accepts a full definitions document directly, export uses an empty POST plus query params, and query/mutate use the query / mutation envelopes.
// POST /definition/import { "schema": { "kinds": { "User": { "dataFields": { "name": { "valueType": "TEXT" }, "email": { "valueType": "EMAIL", "unique": true } }, "linkFields": { "posts": { "relation": "Post", "plays": "author" } } }, "Post": { "dataFields": { "title": { "valueType": "TEXT" } }, "roleFields": { "author": { "playedBy": ["User"], "cardinality": "ONE" } } } } } }
// POST /definition/export?subspace=main // empty request body // → { "data": { "version": "0.34.0", "kinds": { ... } } } // Schema-only payload. For per-field type metadata, see GET /docs/fields/content-types.
// POST /definition/query: all kinds { "$type": "kind" } // Query a specific kind by $did { "$type": "kind", "$did": "K:abc123" } // Query fields of a kind { "$type": "dataField", "$filter": { "ownerKind": "User" } }
// Create a new kind with inline dataFields { "$op": "create", "$type": "kind", "name": "Product", "dataFields": { "title": { "valueType": "TEXT" }, "price": { "valueType": "INTEGER" } } } // Add a roleField to an existing kind { "$op": "create", "$type": "roleField", "ownerKind": "Order", "name": "customer", "playedBy": ["User"], "cardinality": "ONE" } // Add the reverse linkField on the player side { "$op": "create", "$type": "linkField", "ownerKind": "User", "name": "orders", "relation": "Order", "plays": "customer", "target": "relation" }
Queries
Every query is a JSON object sent to POST /query. Results come back as JSON, the shape is predictable based on your query.
Basics
Use $kinds to select by kind, $id to fetch a specific unit, and $fields to control what comes back.
// Returns all User units (array) { "$kinds": "User", "$fields": "*" }
// Returns one unit (object or null) { "$id": "abc123", "$fields": "*" }
{
"$kinds": "User",
"$fields": ["name", "email", "age"],
"$sort": [{ "$field": "age", "$order": "desc" }],
"$limit": 10,
"$offset": 20
}Filters & Search
Use $filter for field-level conditions. Direct values are shorthand for $eq. Use $search for full-text search on fields marked with fts: true. $fuzzy belongs in $filter, not $search: it is case-insensitive, defaults to distance 2, allows $distance from 0 to 10, and auto-sorts closest matches when you do not pass $sort.
{
"$kinds": "User",
"$filter": {
"status": "active",
"age": { "$gte": 18 }
},
"$fields": ["name", "email"]
}{
"$kinds": "User",
"$filter": { "name": { "$fuzzy": "alcie", "$distance": 1 } },
"$fields": ["name"],
"$limit": 5
}{
"$kinds": "Article",
"$search": "rust programming",
"$fields": ["title", "$score"],
"$limit": 10
}Projections
$fields controls what data comes back. Use "*" for all fields, an array for specific ones, or $excludedFields to exclude. Arc fields in $fields return raw IDs. Use $expand to fetch full units instead.
// All fields { "$kinds": "User", "$fields": "*" } // Specific fields only { "$kinds": "User", "$fields": ["name", "email"] } // All except some { "$kinds": "User", "$fields": "*", "$excludedFields": ["password"] } // Arc fields without $expand → raw IDs { "$kinds": "Post", "$fields": ["title", "author"] } // → { "title": "Hello", "author": "user_abc123" }
Expanding Relations
$expand traverses relationships and inlines the connected units. No N+1. BlitzStore batches all traversals automatically. You can nest $expand at any depth, with its own $fields, $filter, $sort, and $limit. Use to include connection timestamps.
{
"$kinds": "Post",
"$fields": [
"title",
{ "$expand": "author", "$fields": ["name", "email"] },
{ "$expand": "comments", "$fields": ["text"] }
]
}{
"$kinds": "User",
"$fields": [
"name",
{
"$expand": "posts",
"$filter": { "status": "published" },
"$sort": [{ "$field": "title", "$order": "asc" }],
"$limit": 5,
"$fields": ["title", "status"]
}
]
}Use $as to rename an expanded or virtual field in the response when the schema name does not read well on the client.
{
"$kinds": "User",
"$fields": [
{ "$expand": "posts", "$as": "articles", "$fields": ["title"] }
]
}Arc Metadata
works like but wraps each result in an metadata envelope with $arcCreatedAt (ISO timestamp). Useful for seeing when connections were made: friendships, memberships, audit trails.
{
"$kinds": "Team",
"$id": "team1",
"$fields": [
"name",
{ "$expandArc": "members", "$fields": ["name"] }
]
}
// Response wraps each member in an arc envelope:
// { "name": "Devs", "members": [
// { "$arcCreatedAt": "2026-03-15T10:30:00Z", "$unit": { "$id": "...", "name": "Alice" } },
// { "$arcCreatedAt": "2026-03-16T09:00:00Z", "$unit": { "$id": "...", "name": "Bob" } }
// ]}Aggregations
$groupBy groups results by field values, but it is not required for a single root summary row. Aggregate with $agg operators like COUNT, SUM, AVG, MIN, MAX, and more. Works at root level and nested inside $expand.
{
"$kinds": "Order",
"$fields": [
{ "%count": { "$agg": "COUNT" } },
{ "%total": { "$agg": "SUM", "$field": "amount" } },
{ "%avg": { "$agg": "AVG", "$field": "amount" } }
]
}Add $groupBy only when you want one aggregate row per group.
{
"$kinds": "Order",
"$groupBy": ["status"],
"$fields": [
"status",
{ "%count": { "$agg": "COUNT" } },
{ "%total": { "$agg": "SUM", "$field": "amount" } }
],
"$sort": [{ "$field": "total", "$order": "desc" }],
"$limit": 5
}{
"$kinds": "Team",
"$fields": [
"name",
{
"$expand": "members",
"$groupBy": ["position"],
"$fields": [
"position",
{ "%count": { "$agg": "COUNT" } }
]
}
]
}{
"$kinds": { "$all": ["Human", "Spanish"] },
"$search": "senior backend rust",
"$filter": { "role": { "$in": ["engineer", "designer"] } },
"$fields": [
"name", "salary", "bonus",
{ "%total": { "$js": "salary + bonus" } },
{
"$expand": "projects",
"$sort": [{ "$field": "budget", "$order": "desc" }],
"$limit": 3,
"$fields": [
"title", "budget", "spent",
{ "%remaining": { "$js": "budget - spent" } }
]
}
]
}Mutations
Send mutations to POST /mutate. Operations are inferred from the shape of your JSON, or set $op explicitly.
Create, Update, Delete
The operation is inferred from your input: $setKinds without $id creates,$id with fields updates, $id alone deletes.
// Create: $setKinds without $id { "$setKinds": ["User"], "name": "Alice", "email": "[email protected]" } // Update: $id with fields { "$id": "user_abc123", "email": "[email protected]" } // Delete: $id without fields { "$id": "user_abc123" } // Bulk delete: with $filter { "$op": "delete", "$kinds": "Task", "$filter": { "done": true } }
$setKinds without $id$id with data fields$id with no data fieldsUpsert
$op: "upsert" updates when the identity resolves an existing unit and creates when it does not. Because it may create, $setKinds is required. Identity can come from either $id or $filter, but never both.
// Kind has idField: "email" // First call creates { "$op": "upsert", "$id": "[email protected]", "$setKinds": ["User"], "email": "[email protected]", "name": "Alice" } // Second call updates same unit { "$op": "upsert", "$id": "[email protected]", "$setKinds": ["User"], "name": "Alice V2" }
// 0 matches -> create { "$op": "upsert", "$setKinds": ["User"], "$filter": { "email": "[email protected]" }, "email": "[email protected]", "name": "Bob" } // 1 match -> update { "$op": "upsert", "$setKinds": ["User"], "$filter": { "email": "[email protected]" }, "name": "Bob V2" }
$id for direct identity, or $filter for a 0-or-1 match lookup.$id + $filter is invalid. A $filter that matches 2+ units is invalid too.Batch Operations
Pass an array for atomic batch mutations. All succeed or all roll back. You can mix creates, updates, and deletes in the same batch. Use $var to reference units across operations, or nest child units directly inside their parent.
[
{ "$var": "_:user", "$setKinds": ["User"], "name": "Alice" },
{ "$var": "_:acct", "$setKinds": ["Account"], "provider": "github" },
{ "$setKinds": ["UserAccount"], "user": "_:user", "account": "_:acct" }
]{
"$setKinds": ["UserAccount"],
"user": { "$setKinds": ["User"], "name": "Alice" },
"account": { "$setKinds": ["Account"], "provider": "github" }
}// User → Tag → Group → Colors, all created atomically { "$setKinds": ["User"], "tags": [{ "$setKinds": ["Tag"], "group": { "$setKinds": ["Group"], "colors": [ { "$setKinds": ["Color"], "name": "Red" }, { "$setKinds": ["Color"], "name": "Blue" } ] } }] }
// Create + update in the same atomic batch [ { "$setKinds": ["User"], "name": "Bob", "status": "new" }, { "$id": "existing_user_id", "status": "updated" } ]
Data Import
POST /data/import is a create-only fast path for seeding datasets. Items without an explicit $op default to create; any other $op is rejected. The builder topo-sorts $var references and chunks large arrays, so a single payload can carry an entire dataset and still respect parent-before-child ordering.
// Country has idField:"code". Send code as data; // $id is used to resolve same-import arcs. POST /data/import { "units": [ { "$id": "country:mex", "$kinds": ["Country"], "code": "MEX", "name": "Mexico" }, { "$id": "country:bra", "$kinds": ["Country"], "code": "BRA", "name": "Brazil" }, { "$id": "sticker:mex-logo", "$kinds": ["Sticker"], "code": "MEX1", "label": "Logo", "country": "country:mex" } ] }
// data_export emits query-like units with $id, $kinds, // data fields, and raw arc IDs. It omits $op, $var, $iid. POST /data/import { "units": [ { "$id": "user:alice", "$kinds": ["User"], "name": "Alice", "posts": ["post:hello"] }, { "$id": "post:hello", "$kinds": ["Post"], "title": "Hello", "author": "user:alice" } ] }
$id in import$id on an imported create object resolves same-import arc references and is stripped before create; send idField values as regular data fields.$id values or $var: "_:<name>" on the parent and reference it from children. The builder pre-allocates ULIDs and topo-sorts. Internal ULIDs are never user-chosen.Accept: text/event-stream to POST /data/import for chunked progress events; omit it for one-shot JSON. opts.batchSize tunes server-side chunking (default 5000) — do not split client-side.Control Flow
$for generates mutation items from a loop; $if includes them conditionally. Control flow is expanded before the engine runs, so the batch stays flat and atomic. Both can nest and mix with regular items.
// Range source (inclusive) { "$for": { "$in": { "$range": [1, 3] }, "$as": "_:i" }, "$do": [ { "$setKinds": ["User"], "name": "user{_:i}", "index": "_:i" } ] } // Array source { "$for": { "$in": ["rust", "graph", "blitz"], "$as": "_:tag" }, "$do": [{ "$setKinds": ["Tag"], "name": "_:tag" }] }
// With optional else branch { "$if": { "$js": "1 > 2" }, "$then": [{ "$setKinds": ["Status"], "value": "then" }], "$else": [{ "$setKinds": ["Status"], "value": "else" }] }
Write {_:var} inside strings to interpolate the loop variable. Total iterations are bounded by query limits to prevent runaway loops.
Graph Operations
Manage connections with $op inside arc fields (see for timestamps). link adds connections, unlink removes them, replace sets them exactly. Direct assignment (DX sugar) is shorthand for replace.
// Link: add connections { "$id": "book1", "authors": { "$op": "link", "$id": "user1" } } // Unlink: remove a connection { "$id": "book1", "authors": { "$op": "unlink", "$id": "user1" } } // Replace: set exact connections { "$id": "book1", "authors": { "$op": "replace", "$id": ["user1", "user2"] } } // DX sugar: direct assignment = replace { "$id": "book1", "authors": ["user1", "user2"] }
{
"$id": "book1",
"authors": {
"$op": "link",
"$kinds": "User",
"$filter": { "role": "dev" }
}
}For linkFields with target: "role", direct endpoint-shaped writes are rejected. Use a projected endpoint update for existing related units, or create the relation tree explicitly.
// Query projected endpoints through a tunnel link { "$kinds": "Candidate", "$fields": [ "name", { "$expand": "interviewers", "$fields": ["name", "department"] } ] } // Update the projected endpoints selected by the tunnel { "$id": "candidate_123", "interviewers": { "$op": "update", "$filter": { "department": "Ops" }, "department": "Platform" } }
{
"$id": "candidate_123",
"interviewers": {
"$setKinds": ["Interview"],
"date": "2026-04-11T09:00:00Z",
"interviewer": {
"$setKinds": ["Interviewer"],
"name": "Dana",
"department": "Platform"
}
}
}Expressions
Use $js for inline JavaScript expressions. Reference sibling fields by name, or batch variables with _:varname. Fields are evaluated in dependency order; circular dependencies are detected and rejected.
{
"$setKinds": ["Invoice"],
"subtotal": 100,
"tax": { "$js": "subtotal * 0.21" },
"total": { "$js": "subtotal + tax" }
}{
"$setKinds": ["Post"],
"title": "Hello World",
"slug": { "$js": "title.toLowerCase().replaceAll(' ', '-')" }
}// Query an existing user, then create a post using their data [ { "$op": "query", "$var": "_:author", "$kinds": "User", "$id": "user1" }, { "$setKinds": ["Post"], "title": "Hello World", "authorName": { "$js": "_:author.name" }, "boost": { "$js": "_:author.karma * 0.1" }, "greeting": { "$js": "`Hello ${_:author.name}!`" } } ]
$js also works in queries as virtual fields, computed values that exist only in the response, prefixed with %.
{
"$kinds": "Order",
"$fields": [
"name", "price", "quantity",
{ "%line_total": { "$js": "price * quantity" } },
{ "%tax_label": { "$js": "'Tax: ' + (price * 0.21).toFixed(2)" } }
]
}Force a JSON value into a specific type at write time. The canonical form is the object key with a # prefix — the $ namespace is reserved for BQL operators and selectors.
{
"$setKinds": ["Invoice"],
"created": { "#datetime": "2024-01-15T10:30:00Z" },
"dueDate": { "#date": "2024-02-15" },
"openAt": { "#time": "09:00:00.000" },
"price": { "#decimal": "19.99" },
"eta": { "#duration": "1h30m" },
"active": { "#boolean": "true" },
"count": { "#integer": "42" },
"ratio": { "#float": "3.14" },
"owner": { "#unit": "01J..." },
"rawText": { "#text": ".name" }
}#datetime requires a timezone (Z or +02:00). #decimal preserves exact base-10. #duration combines w/d/h/m/s/u/n and supports negatives. #text escapes a string that would otherwise be parsed as DX sugar (e.g. ".name" inside a native expression). Six casts (unit / date / datetime / time / decimal / duration) also accept the legacy DX string-prefix shortcut "<datetime>2024-01-15T10:30:00Z"; the others are object-form only.
An INTERVAL field stores a set of ranges. Probe with $contains (full containment of a point or sub-set) and $intersects (any overlap).
// Write: a Schedule with two open ranges { "$setKinds": ["Schedule"], "officeHours": { "#intervals": [ { "start": { "value": "09:00:00.000", "inclusive": true }, "end": { "value": "13:00:00.000", "inclusive": false } }, { "start": { "value": "14:00:00.000", "inclusive": true }, "end": { "value": "18:00:00.000", "inclusive": false } } ] } } // Schedules whose office hours contain 10:30 { "$kinds": "Schedule", "$filter": { "officeHours": { "$contains": { "#time": "10:30:00.000" } } } } // Schedules whose office hours overlap a meeting window { "$kinds": "Schedule", "$filter": { "officeHours": { "$intersects": { "#intervals": [{ "start": { "value": { "#time": "12:30:00.000" }, "inclusive": true }, "end": { "value": { "#time": "15:30:00.000" }, "inclusive": true } }] } } } }
$expr is a checked fast lane for selected hot-path functions, named @namespace.fn. Inside an @-call, a string starting with . is DX sugar for a field reference; wrap in #text to keep the literal string. $js remains the broad surface for anything not covered yet.
{
"$kinds": "User",
"$fields": [
"name",
{ "%fullName": { "$expr": { "@text.concat": [".name", " ", ".familyName"] } } }
]
}Responses
All responses follow a consistent structure. The shape of data is predictable based on your query.
Response Shape
The data field is an object or null when the selector is provably single: scalar $id, scalar $iid, or an equality filter on a unique data field (e.g. $filter: { "email": "x" } where email is unique: true). Null equality on a unique field stays MANY. Queries driven by $kinds alone, non-unique filters, $id.$in, root $or, or $in return an array, even if the filter matches one row. $limit: 1 does not change response shape.
{
"data": {
"$id": "abc123",
"$kinds": ["User"],
"name": "Alice",
"email": "[email protected]"
}
}{
"data": [
{ "$id": "abc123", "$kinds": ["User"], "name": "Alice" },
{ "$id": "def456", "$kinds": ["User"], "name": "Bob" }
],
"meta": { "count": 2, "timing_ms": 1.23 }
}Add $explain to a query or mutation to include a plan in the response. "basic" returns the step list and row counts; "full" adds per-step timing. Useful for tuning.
{ "$kinds": "User", "$filter": { "status": "active" }, "$explain": "full" }
// Response shape
{
"data": [ ... ],
"meta": { "count": 10, "timing_ms": 1.8 },
"explain": { "steps": [ ... ], "rows_scanned": 25000, "timing_ms": { ... } }
}Metadata
Every unit includes $id and $kinds by default. $meta is only valid inside $fields. $meta is not a root query key; include it there to get all always-on meta fields. Contextual meta ($score, $history) must be requested explicitly.
Error Handling
The HTTP status mirrors the body. Empty errors[] → 200 OK. Non-empty → 422 Unprocessable Entity (BQL/schema/business rejection). Other 4xx/5xx are transport-level.
errorsare hard failures with stableSCREAMING_SNAKE_CASEcodeswarningsare non-fatal (operation still succeeds)infoare informational messages
{
"data": null,
"errors": [
{ "code": "UNKNOWN_KIND", "message": "unknown kind 'Userr'. Did you mean 'User'?" }
],
"meta": { "timing_ms": 0.12 }
}Branch on error.code with the typed catalog exported by @blitzgraph/client-core (SERVER_ERROR_CODES, isKnownErrorCode()) — the catalog is generated from the server, so unknown codes mean a stale client.
By default, mutations accumulate all errors so you see every problem at once. Set failFast: true in mutation options to stop on the first error instead.