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. Three 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_*: unrestricted per-instance internal keys reserved for explicit break-glass recovery.bzc_*: 60-second service capabilities bound to one instance, HTTP action, and database/namespace scope. Selected internal service routes accept the matching action; key and blob administration remainbzi_*-only.
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 issue or reuse a scoped bzt_* for the logged-in user. The dataserver only ever sees the minted bzt_*.
The MCP OAuth connect flow authorizes the issued connection grant against a Space, not a fixed namespace or server. Every call resolves the Space's current placement live, so relocating a Space never invalidates an existing grant. Tools inherit the selected namespace target; use opts.defaultSubspace when one call should target a different subspace default.
Platform
Storage scopes isolate data; live queries keep it synchronized; durable workflows orchestrate work; effective limits explain capacity; and BlitzStudio operates all of those surfaces.
Namespaces & Subspaces
A is the top-level isolation boundary inside a database. Each has its own schema, data, and indexes. A is another isolation boundary inside that namespace: data, schema, portals, and indexes are scoped to it. Free-tier users get one namespace and can create multiple subspaces (e.g. main, archive, drafts).
// HTTP envelope default { "query": { "$kinds": "Note", "$fields": "*" }, "opts": { "defaultSubspace": "archive" } } // Per-query override { "$kinds": "Note", "$subspace": "archive", "$fields": "*" } // Per-mutation override { "$setKinds": ["Note"], "content": "temp", "$subspace": "drafts" } // Omit $subspace → defaults to "main" { "$kinds": "Note", "$fields": "*" }
// POST /admin: create a subspace { "admin": { "$resource": "subspace", "$op": "create", "$rid": "ss:archive", "storage": "memory" } } // POST /admin: list subspaces { "admin": { "$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 with plus opts.defaultSubspace, or via ns.defaultSubspace("name").schema.import() in the SDK.
For namespace-scoped self-service transfer, use the /namespace/bundle/* and /subspace/bundle/* routes. They stream strict .bzg bundles and run imports detached, so the same bundle can be re-uploaded to resume a matching interrupted import.
Per-unit $history is unbounded by default. Set history.retentionDays and/or history.maxEventsPerUnit in the namespace config to bound it; a background sweep removes events past the retention window or cap, and $history reads only see surviving entries.
Live Queries
A keeps one ordered BQL result synchronized over SSE. The rawPOST /query/live lane is for scoped grants and internal keys. Portal sessions subscribe only through a validated named query operation at /_ops/query/{op}/live, so a browser never gains a raw-query capability.
for await (const frame of client.queryLive({
query: { $kinds: 'Ticket', $sort: ['priority', '$id'], $fields: '*' },
subspace: 'main',
resume: lastAppliedCheckpoint,
})) {
// Persist frame.checkpoint only after the whole snapshot/patch applies.
applyAtomically(frame)
}// Scoped grant / internal-key application const tickets = useLiveQuery({ $kinds: 'Ticket', $sort: ['$id'], $fields: '*' }) // Portal AppUser: generated named-operation ref const myTickets = useLiveOperation(myTicketsRef, { status: 'open' })
A retryable LIVE_CONTINUITY_GAP means the server fenced a gap and is repairing it. LIVE_RESUME_UNAVAILABLE is stable: an operator must configure BLITZSTORE_SECRET_LIVE_RESUME_KEYRINGbefore resume is available. The client store backs off reconnects and falls back to a fresh authorized snapshot when a retained replay can no longer be used.
Actions & Workflows
Available in BlitzGraph 0.61.0. An Action is a typed reusable step; a is a durable state machine that orchestrates Actions, BQL, waits, signals, nested workflows, and bounded fan-out. Definitions are linted before persistence, and every run pins its executable closure and program digest.
{ $op: 'create', $type: 'action', name: 'scoreLead',
type: 'pure', mode: 'inline',
inputSchema: { type: 'object', properties: { score: { type: 'number' } } },
outputSchema: { type: 'number' },
$ts: 'return (input as { score: number }).score satisfies number'
}{ $op: 'create', $type: 'action', name: 'protectedScore',
type: 'pure', mode: 'wasm',
module: 'scorer@sha256:<64 lowercase hex>',
inputSchema: { type: 'object' },
outputSchema: { type: 'number' }
}Inline code accepts $js, $ts, or explicit $code. TypeScript is stored as authored source, but the server rejects imports, modules, JSX, decorators, enums, namespaces, and other runtime-generating syntax before a definition persists. Studio diagnostics use the same generated restricted-profile manifest; server lint remains authoritative.
const uploaded = await client.uploadCodeModule('scorer', wasmBlob)
const pinned = uploaded.data.module // name@sha256:<digest>, abiVersion: 1
await client.listCodeModules()
await client.getCodeModule(pinned)
const originalBytes = await client.downloadCodeModule(pinned)
await client.deleteCodeModule(pinned) // rejected while an Action references itWASM is available only for pure×wasm. Components exportprocess(string) → result<string, string>, have no host imports, and run with bounded memory, output, errors, logs, cache bytes, and deadlines. The ABI is pinned and validated by durable workflow wire V6; callers do not author anabiVersion field.
const run = await client.runWorkflow('onboardCustomer', {
idempotencyKey: 'signup:customer-01J...',
trigger: { customerId: 'customer-01J...' },
})
const runId = run.data.runId
const status = await client.getTask(runId)
for await (const event of client.taskStream(runId)) {
console.log(event)
}Crash recovery resumes the full caller stack and the remaining compensation cursor. Mapped child membership is recorded before dispatch, so joins and cancellation do not lose children after a worker handoff. MCP exposes the same lifecycle throughworkflows.run, workflows.runStatus, and task/event tools.
Effective Limits
An is the value the server actually enforces after defaults, configuration, environment overrides, and request scope are resolved. The catalog and rejection details are projections of that same object, so documented and enforced values cannot drift.
const { data: limits } = await client.effectiveLimits()
for (const limit of limits) {
console.log(limit.limitId, limit.effectiveValue, limit.unit)
}GET /limits/effective
// MCP tool: no input body
limits.effectiveBlitzStudio
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 /definitions/import,/definitions/query,/definitions/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
extends; 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": { "extends": "User", "dataFields": { "level": { "valueType": "INTEGER" } } },
"SuperAdmin": { "extends": "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 $createdAt.
{
"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"
}]
}
}
}
}
},
"opts": { "defaultSubspace": "main" }
}{
"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,$createdAt,$updatedAt, 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@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
}
}
}- Order is transform → validate → effect
- Validate hooks see the final transaction state and can also inspect
$input - Hooks targeting ancestor kinds can include descendants with
$descendants - Transform hooks can return patches inline or with an explicit
return - Hook bodies accept
$js,$ts(types erased), or a pinned WASMremotemodule
- Hooks mutate
$this; cross-unit cascades are not supported yet unit.effectruns post-commit; blocking checks belong inunit.validate- Transform hooks on
linkare not implemented yet - Non-converging transform loops fail with max-depth protection
When a unit is deleted and that break severs its relationships, each break surfaces to the surviving unit's update hooks as an unlink event in $delta.arcs. The same stream also appears in $history and in normalized mutation output, so a survivor can react to losing a connection.
{
"hooks": {
// Post survives when its author User is deleted; the broken arc
// arrives as an unlink entry in $delta.arcs on the survivor's update.
"Post.markUnlinkSeen": {
"type": "unit.transform",
"target": {
"ops": ["update"],
"$kinds": { "$any": ["Post"] }
},
"$js": "var arcs = ($delta && $delta.arcs) || []; return { unlinkSeen: arcs.some(a => a.$op === 'unlink') };"
}
}
}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.{ "#intervals": ... } input shape. Results carry no cast key; their shape follows the field's intervalFormat.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 role field 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": {
"roleFields": {
"friends": { "playedBy": ["User"], "cardinality": "MANY", "symmetric": true }
}
},
"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/export use direct JSON bodies with opts.defaultSubspace. Query/mutate use the query / mutation envelopes.
Every definitions import body (/definitions/import, /schema/import, /portals/import, /automations/import) must carry $definitionContract — read the fingerprint from GET /health ($definitionContract field), or let the JS SDK stamp it automatically; a body without it is rejected with 422.
// POST /definitions/import { // Required: this build's definitions wire fingerprint. // Read it from GET /health ("$definitionContract"); the JS SDK stamps it for you. "$definitionContract": "<value from GET /health>", "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" } } } } }, "opts": { "defaultSubspace": "main" } }
// POST /definitions/export { "opts": { "defaultSubspace": "main" } } // → { "data": { "$bzv": "0.34.0", "$definitionContract": "…", "schema": { "kinds": { ... } }, "portals": { ... } } } // The exported "$definitionContract" round-trips straight back into /definitions/import. // For schema-only export: POST /schema/export with the same opts shape. // For per-field type metadata: GET /docs/fields/content-types.
The definition endpoints above move schema and portals only. Store-level operators can move an entire namespace with stored artifacts (/namespace/export and /namespace/import). Namespace-scoped users use .bzg bundle routes instead: the bundle carries the same verified payload, imports run detached, and re-uploading the same bundle resumes a matching interrupted import.
// Preview bundle contents GET /namespace/bundle/plan // Download a .bzg bundle (writes pause only while the artifact is staged) POST /namespace/bundle/export { "include": { "definitions": true, "units": true, "files": false }, "subspaces": ["main"] } // Upload the bundle; import completes asynchronously POST /namespace/bundle/import?definitions=true&units=true&files=false Content-Type: multipart/form-data [email protected] // → 202 { "importId": "01...", "resumed": false } // Poll GET /admin/import-status until terminal.importId matches.
FILE round-trip is controlled by include.files. On bundle routes it defaults to false to avoid accidental egress; set it to true to carry all blobs or to a { "perSubspace": { ... } } object for selected FILE fields. Store-level artifact routes default include.files totrue because the transfer stays server-side.
App-user authorization is also authored in definitions. A subject kind marks itself with userKind and an email identity field. Protected kinds use permissions rules evaluated with $me. Portals use access rules for app/page/endpoint admission.
{
"schema": {
"kinds": {
"Member": {
"userKind": true,
"idField": "email",
"emailField": "email",
"dataFields": {
"email": { "valueType": "EMAIL", "unique": true },
"name": { "valueType": "TEXT" }
}
},
"Admin": {
"extends": "Member",
"userKind": true
},
"Note": {
"dataFields": {
"title": { "valueType": "TEXT" },
"privateText": { "valueType": "TEXT" }
},
"roleFields": {
"owner": { "playedBy": ["Member"], "cardinality": "ONE" }
},
"permissions": {
"query": "owner",
"create": { "$me.$kinds": "Member" },
"fields": {
"privateText": { "query": { "$me.$kinds": "Admin" } }
}
}
}
}
},
"env": {
"domains": {
"notes-auth": {
"secrets": ["password_pepper", "resend_api_key"]
}
}
},
"portals": {
"apps": {
"notes": {
"slug": "notes",
"env": ["notes-auth"],
"access": { "$kinds": ["Member"] },
"auth": {
"providers": {
"emailPassword": {
"enabled": true,
"pepper": { "$secret": "env.notes-auth.password_pepper" }
}
},
"delivery": {
"provider": "resend",
"apiKey": { "$secret": "env.notes-auth.resend_api_key" },
"fromAddress": "Notes <[email protected]>"
},
"signup": {
"mode": "open",
"blueprint": {
"$setKinds": ["Member"],
"$authAnchorKind": "Member",
"email": { "$auth": "email" },
"name": { "$input": "name" }
}
}
},
"pages": {
"/": {
"tsxSource": "export default function NotesPage() { return null }"
}
}
}
}
}
}// POST /definitions/query: all kinds { "query": { "$type": "kind" }, "opts": { "defaultSubspace": "main" } } // Query a specific kind by $did { "query": { "$type": "kind", "$did": "K:abc123" } } // Query fields of a kind { "query": { "$type": "dataField", "$filter": { "ownerKind": "User" } } }
// POST /definitions/mutate { "mutation": [ { "$op": "create", "$type": "kind", "name": "Product", "dataFields": { "title": { "valueType": "TEXT" }, "price": { "valueType": "INTEGER" } } }, { "$op": "create", "$type": "roleField", "ownerKind": "Order", "name": "customer", "playedBy": ["User"], "cardinality": "ONE" }, { "$op": "create", "$type": "linkField", "ownerKind": "User", "name": "orders", "relation": "Order", "plays": "customer", "target": "relation" } ], "opts": { "defaultSubspace": "main" } }
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": [{ "$by": "age", "$order": "desc" }],
"$limit": 10,
"$offset": 20
}For large result sets, prefer $cursor over $offset. Pass $cursor together with $limit; the response returns an opaque meta.$nextCursor token that you replay verbatim as the next $cursor. Absence of meta.$nextCursor means you reached the last page. Never parse or construct the token. Cursor paging requires $limit, excludes $offset, is root-query only, and is rejected with $groupBy and root aggregates.
// First page { "$kinds": "Task", "$limit": 50 } // → { "data": [ ... ], "meta": { "count": 50, "$nextCursor": "eyJ..." } } // Next page: replay meta.$nextCursor verbatim as $cursor { "$kinds": "Task", "$limit": 50, "$cursor": "<token from meta.$nextCursor>" } // No meta.$nextCursor in the response → last page
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
}null equality means empty content, not a stored JSON null:{ "tags": null } folds to { "tags": { "$isEmpty": true } }. Use $exists when you need presence, and use $has, $any, $all, or $exact for MANY fields.
{
"$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. When composed kinds define the same field name differently, use $definedOn to select one defining kind; a genuinely ambiguous multi-kind row returns an $ambiguous marker instead of guessing.
// 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" }
{
"$kinds": ["Bot", "User"],
"$fields": [
{ "$fetch": "name", "$definedOn": "User", "$as": "userName" },
{ "$fetch": "name", "$definedOn": "Bot", "$as": "botName" }
]
}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": [{ "$by": "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" } }
// ]}Inside you can also filter and sort the relationships by when each connection was created, using $arcCreatedAt as a filter key and a sort key. The arc-time predicate is evaluated before the connected units are fetched, so non-matching connections are pruned early.
{
"$kinds": "Team",
"$id": "t1",
"$fields": [
{
"$expandArc": "members",
"$filter": { "$arcCreatedAt": { "$gte": { "#datetime": "2026-01-01T00:00:00.000Z" } } },
"$sort": "-$arcCreatedAt",
"$limit": 10
}
]
}- Comparison operators only:
$gte,$gt,$lte,$lt,$eq,$neq - As a filter,
$arcCreatedAtmust be a top-level condition (or inside a top-level$and) - As a sort key it must be the primary (first) sort key
- Valid only inside , never in plain
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",
"$aggFields": [
{ "%count": { "$agg": "COUNT" } },
{ "%total": { "$agg": "SUM", "$field": "amount" } },
{ "%avg": { "$agg": "AVG", "$field": "amount" } }
]
}$groupBy emits distinct group-key rows by itself; add $aggFields when those rows need metrics. $groupBy.$filter is HAVING, and the $groupBy object's $sort, $limit, and $offset order and paginate groups. Node-level $sort / $limit / $offset window candidate units before grouping. Group keys may be stored or computed scalar fields, ONE arc identities, or authored $js/$ts/$code/$expr expressions with an $as name. MANY-valued keys reject explicitly rather than creating ambiguous groups.
{
"$kinds": "Order",
"$groupBy": {
"$by": ["status"],
"$filter": { "total": { "$gte": 1000 } },
"$sort": [{ "$by": "total", "$order": "desc" }],
"$limit": 5
},
"$aggFields": [
{ "%count": { "$agg": "COUNT" } },
{ "%total": { "$agg": "SUM", "$field": "amount" } }
]
}{
"$kinds": "Order",
"$groupBy": {
"$by": [
"customer",
{ "$js": "status.trim().toLowerCase()", "$as": "normalizedStatus" }
]
},
"$aggFields": [
{ "%count": { "$agg": "COUNT" } }
]
}{
"$kinds": "Team",
"$fields": [
"name",
{
"$expand": "members",
"$groupBy": ["position"],
"$aggFields": [
{ "%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": [{ "$by": "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.Batch Size Limits
db.mutate() caps a single call at 10,000 operations by default; definition_mutate caps at 500 items. Every root and nested mutation node (create, update, upsert, delete, query) counts, plus each explicit arc link / unlink / replace target. A filter-scoped bulk op counts as one regardless of how many units it matches — the cap bounds input batch size, not match count. Nodes are counted after $for / $if expansion.
Breaching the cap returns BATCH_TOO_LARGE, which points data callers at chunked data_import / namespace import and definition callers at smaller batches. Raise the cap per-call (up to the server hard cap) or per-namespace (mutation.maxBatchSize / definitionMutation.maxBatchSize, raise-only — namespace config cannot lower the server default). maxTotalGeneratedItems (100,000) stays the absolute $for expansion ceiling even when maxBatchSize is raised.
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 or $ts for TypeScript (types are erased before execution; the explicit form is $code: { $lang, $body }). Works on create and update data-field writes (including upsert and scoped arc updates). Create sees sibling fields only; update sees the stored row plus siblings, topo-ordered. Self-reference reads the pre-write stored value. Batch variables use _:varname. Circular sibling dependencies are rejected. Hooks see the authored expression, not the evaluated value. Test absence with isNull(x); DECIMAL locals are objects — use decimal().
An expression that returns null clears its target, just like a literal null. Runtime failures fail the mutation by default; on updates,onExprError: "skip" leaves only the failing field unwritten and reports a warning. Code-shaped objects inside JSON-operation values or arrays are literal JSON, not executable expressions.
{
"$setKinds": ["Invoice"],
"subtotal": 100,
"tax": { "$js": "subtotal * 0.21" },
"total": { "$js": "subtotal + tax" }
}{
"$id": "line1",
"total": { "$js": "price * qty" },
"count": { "$js": "count + 1" }
}{
"$setKinds": ["Post"],
"title": "Hello World",
"slug": { "$js": "title.toLowerCase().replaceAll(' ', '-')" }
}{
"$setKinds": ["Invoice"],
"subtotal": 100,
"total": { "$ts": "const rate: number = 0.21; subtotal * (1 + rate)" }
}// 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 ranges. Compact notation is the // shortest spelling; a member array of nested tuples also works. { "$setKinds": ["Schedule"], "officeHours": { "#intervals": "[09:00:00.000,13:00:00.000) U [14:00:00.000,18:00:00.000)" } } // Read: results never echo the "#intervals" cast. The shape follows the // field's intervalFormat — "tuple" here, so bounds render as pairs: // { "officeHours": [["09:00:00.000","13:00:00.000"], ["14:00:00.000","18:00:00.000"]] } // 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": [[{ "#time": "12:30:00.000" }, { "#time": "15:30:00.000" }]] } } } }
$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]"
},
"outcome": "completed"
}{
"data": [
{ "$id": "abc123", "$kinds": ["User"], "name": "Alice" },
{ "$id": "def456", "$kinds": ["User"], "name": "Bob" }
],
"outcome": "completed",
"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": [ ... ],
"outcome": "completed",
"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. Timestamp fields use the canonical public keys $createdAt and $updatedAt; both serialize as ISO strings.
Outcomes and Issues
outcome is the operation state. Completed, committed, and partial outcomes use 200; accepted work uses 202; rejected work normally uses 422 (or 429 for backpressure).
issuesis one flat, severity-sorted diagnostic listseverityis error, warning, or infophaseis request, execution, commit, or post_commit
A query whose $filter matched zero units (and that was not an identity / $offset lookup), or that used an empty $in: [], still succeeds with data: [] but carries a warning issue explaining the empty result. Every issue has a stable code; issue-specific context stays in details.
{
"data": [],
"outcome": "completed",
"issues": [
{ "code": "FILTER_MATCHED_NOTHING", "severity": "warning", "phase": "execution", "field": "$filter", "message": "filter matched 0 units" }
],
"meta": { "count": 0, "timing_ms": 0.21, "issues": { "total": 1, "returned": 1, "omitted": 0, "errors": 0, "warnings": 1, "info": 0 } }
}{
"data": null,
"outcome": "rejected",
"issues": [
{ "code": "UNKNOWN_KIND", "severity": "error", "phase": "execution", "message": "unknown kind 'Userr'. Did you mean 'User'?" }
],
"meta": { "timing_ms": 0.12 }
}Branch on issue.code with the typed catalog exported by @blitzgraph/client-core (SERVER_ISSUE_CODES, with separate error and diagnostic partitions) — 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.