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.
Platform
BlitzStore organizes data into namespaces and subspaces, and provides BlitzStudio as a visual interface.
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" }
POST /admin takes the admin operation itself as the JSON body. There is no body wrapper and no opts envelope.
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 .subspace("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/importstreams progress over SSE and requiresAccept: text/event-stream
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": { "type": "TEXT" },
"email": { "type": "EMAIL", "unique": true },
"age": { "type": "NUMBER" }
}
},
"Article": {
"dataFields": {
"title": { "type": "TEXT", "required": true, "fts": true },
"body": { "type": "TEXT", "fts": true },
"status": { "type": "TEXT" }
}
}
}
}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": {
"type": "NUMBER",
"validations": { "required": true, "min": 0, "max": 150 }
},
"email": { "type": "EMAIL" },
"backupEmail": {
"type": "TEXT",
"validations": {
"custom": [{
"on": ["create", "update"],
"$js": "$value.includes('@')",
"message": "must be a valid email",
"severity": "error"
}]
}
}
}
}
}
}{
"kinds": {
"User": {
"idField": "email",
"dataFields": {
"firstName": { "type": "TEXT" },
"familyName": { "type": "TEXT" },
"email": { "type": "EMAIL", "unique": true },
"fullName": {
"type": "FLEX",
"computeType": "computed",
"$js": "firstName + ' ' + familyName"
},
"displayId": {
"type": "FLEX",
"computeType": "computed",
"$js": "$id"
},
"role": {
"type": "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 are defined on kinds and run inside the mutation pipeline. Use them when a write needs to derive fields, validate a final draft across multiple fields, or trigger side effects around the commit.
{
"kinds": {
"BlogPost": {
"dataFields": {
"title": { "type": "TEXT" },
"slug": { "type": "TEXT" },
"status": { "type": "TEXT" },
"word_count": { "type": "NUMBER" }
},
"hooks": {
// transform: derive or normalize fields before validation
"transform": [
{
"name": "generate_slug",
"on": ["create", "update"],
"when": { "$js": "$this.title" },
"$js": "return { slug: $this.title.toLowerCase().replace(/\s+/g, '-') }"
},
{
"name": "default_status",
"on": ["create"],
"when": { "$js": "!$this.status" },
"$js": "({ status: 'draft' })"
}
],
// validate: reject an invalid final draft state
"validate": [{
"name": "require_title",
"on": ["create", "update"],
"$js": "$this.title && $this.title.length > 0",
"message": "Title is required",
"severity": "error"
}],
// effect: run side effects before or after commit
"effect": [{
"name": "post_log",
"on": ["create"],
"$js": "true",
"timing": "post"
}]
}
}
}
}{
"kinds": {
"Person": {
"dataFields": {
"name": { "type": "TEXT" }
},
"hooks": {
"transform": [{
"name": "upcase",
"on": ["create", "update"],
"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 defines who can connect and how many ()
- A gives the other side a shortcut to traverse back
- are kinds too, so they can carry their own data fields
- A can also project directly to a role with
target: "role"andtargetRoles
// Candidate and JobPosition are regular kinds. // Interview is a relation that connects them and carries its own data. { "kinds": { "Candidate": { "dataFields": { "name": { "type": "TEXT" }, "email": { "type": "EMAIL" } }, "linkFields": [ { "path": "interviews", "relation": "Interview", "plays": "candidate" } ] }, "JobPosition": { "dataFields": { "name": { "type": "TEXT" }, "status": { "type": "TEXT" } }, "linkFields": [ { "path": "interviews", "relation": "Interview", "plays": "position" } ] } }, "relations": [{ "name": "Interview", "dataFields": { "date": { "type": "DATE" }, "score": { "type": "NUMBER" }, "notes": { "type": "TEXT" } }, "roles": { "candidate": { "playedBy": ["Candidate"], "cardinality": "ONE" }, "position": { "playedBy": ["JobPosition"], "cardinality": "ONE" } } }] }
{
"kinds": {
"Candidate": {
"dataFields": { "name": { "type": "TEXT" } },
"linkFields": [
{ "path": "interviews", "relation": "Interview", "plays": "candidate", "target": "relation" },
{
"path": "interviewers",
"relation": "Interview",
"plays": "candidate",
"target": "role",
"targetRoles": ["interviewer"]
}
]
},
"Interview": {
"roleFields": {
"candidate": { "plays": ["Candidate"], "cardinality": "ONE" },
"interviewer": { "plays": ["Interviewer"], "cardinality": "ONE" }
}
},
"Interviewer": {
"dataFields": { "name": { "type": "TEXT" }, "department": { "type": "TEXT" } }
}
}
}required for exactly 1.min and max bounds.Schema Operations
Three endpoints manage at runtime: import, query, and mutate.
// POST /definition/import { "kinds": { "User": { "dataFields": { "name": { "type": "TEXT" }, "email": { "type": "EMAIL", "unique": true } } } }, "relations": [{ "name": "Post", "dataFields": { "title": { "type": "TEXT" } }, "roles": { "author": { "playedBy": ["User"], "cardinality": "ONE" } } }] }
// POST /definition/query: all kinds { "$type": "kind" } // Query a specific kind by $did { "$type": "kind", "$did": "K:abc123" } // Query fields of a kind { "$type": "field", "$kind": "User" }
// Create a new kind { "$op": "create", "$type": "kind", "name": "Product", "fields": [ { "name": "title", "valueType": "Text" }, { "name": "price", "valueType": "Number" } ] } // Add a field to an existing kind { "$op": "create", "$type": "field", "$kind": "Product", "name": "stock", "valueType": "Number" }
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.
{
"$kinds": "User",
"$filter": {
"status": "active",
"age": { "$gte": 18 }
},
"$fields": ["name", "email"]
}{
"$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"]
}
]
}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. Since are full kinds, they can carry their own data fields too.
{
"$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" } }
// ]}// Relations are full kinds, so they can have data fields. // A "Writing" relation between Author and Book with year + role: { "$kinds": "Author", "$fields": [ "name", { "$expand": "writings", "$fields": ["year", "role", { "$expand": "book", "$fields": ["title"] } ], "$sort": [{ "$field": "year", "$order": "desc" }] } ] }
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. Batches are atomic: all succeed or all fail.
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" } ]
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", "$ids": ["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)" } }
]
}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 }
}Metadata
Every unit includes $id and $kinds by default. Include $meta in $fields to get all metadata fields.
Error Handling
Responses can include three levels of feedback:
errorsstop the operation (or accumulate, depending on options)warningsare non-fatal (operation still succeeds)infoare informational messages
{
"data": null,
"errors": [
{ "code": "UnknownKind", "message": "unknown kind 'Userr'. Did you mean 'User'?" }
],
"meta": { "timing_ms": 0.12 }
}By default, mutations accumulate all errors so you see every problem at once. Set fail_fast: true in mutation options to stop on the first error instead.