BlitzGraph beta · occasional interruptions may occur
Documentation

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.

JSON in, JSON out
No SQL strings, no ORM chains
Graph traversal
Expand relations without N+1
Atomic mutations
Batch operations, all or nothing

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 remain bzi_*-only.
BQL · first call with a grant token
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 boundary

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_*.

MCP target binding

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).

BQL · target a subspace in queries and mutations
// 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": "*" }
BQL · create and manage subspaces (admin API)
// 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.

BQL · raw SDK subscription
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)
}
BQL · React and portal hooks
// 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' })
Atomic LiveFrame
Snapshots, patches, heartbeats, and terminal frames share one generated contract. A patch is all-or-nothing.
Opaque resume
Reconnect with the last applied checkpoint. Never parse, edit, or persist a checkpoint before its frame applies.
Fail-closed authority
Expiry, revocation, subject-floor changes, and operation-policy changes are revalidated before protected delivery.

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.

BQL · inline TypeScript action
{ $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'
}
BQL · protected WASM action
{ $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.

BQL · namespace-scoped module lifecycle
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 it

WASM 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.

BQL · run once, inspect or stream the durable task
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)
}
Durable control flow
Waits, signals, retries, nested calls, keyed concurrency, and saga compensation survive process restarts.
Bounded map
Map over a named Action or Workflow with bounded concurrency, ordered results, retries, and throw or collect behavior.
Inspectable recovery
Typed events expose child waves, joins, retries, compensation, owning workflow, and W3C trace correlation.

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.

BQL · SDK
const { data: limits } = await client.effectiveLimits()

for (const limit of limits) {
  console.log(limit.limitId, limit.effectiveValue, limit.unit)
}
BQL · HTTP / MCP
GET /limits/effective

// MCP tool: no input body
limits.effective
Exact value and unit
Configured, effective, and observed values use generated units and stable limit identities.
Authority scoped
Database and namespace callers see only their exact effective scope; AppUser and guest access is denied.
Actionable rejection
retryAfterMs plus retryLater or reduceRequest tells clients how to respond without parsing messages.
#

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.

BlitzSheet
Spreadsheet-style data browser. View, edit, create, and delete units in a grid. Filter and sort by any field.
QueryStudio
Live BQL console. Write queries and mutations, see results in real-time as a JSON tree.
Schema Editor
Visual schema management. Create kinds, define fields, set up roles and relations.
Apps (Portals)
Deploy custom frontends that connect to your BlitzStore data. Each app gets its own route, served from your namespace.
Namespace transfer
Export stored artifacts or download .bzg bundles, import into a fresh target, and poll transfer status without leaving Studio.
Env vault
Manage namespace constants and write-only sealed secrets through the typed env admin surface.

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 ).

data ── units, arcs, indexes
definitions
schema ── kinds, dataFields, roleFields, linkFields
portals ── apps, pages, layouts, components
  • POST /query and POST /mutate operate on data
  • POST /definitions/import, /definitions/query, /definitions/mutate operate on definitions (see )
  • POST /admin uses raw admin JSON, not a { body, opts } envelope
  • POST /data/import returns JSON by default; add Accept: text/event-stream for 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
BQL · schema definition
{
  "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" }
      }
    }
  }
}
TEXT
Strings
INTEGER
Whole numbers (i64)
DECIMAL
Exact base-10 (money)
FLOAT
IEEE 754 f64 (ML, science)
PERCENTAGE
Decimal-backed 0–1
CURRENCY
Money: amount + ISO 4217
BOOLEAN
true / false
DATE
YYYY-MM-DD
DATETIME
ISO with TZ (required)
TIME
HH:MM:SS.SSS
DURATION
Combined w/d/h/m/s
INTERVAL
Set of ranges (#intervals in, intervalFormat out)
EMAIL
With validation
URL
With validation
FILE
Upload via #file marker
RICH_TEXT
Rich text payload
COLOR
Color value
PHONE
Phone text
PASSWORD
Secret text
JSON
Nested objects
FLEX
Any type
ID
Identifier
REF
Unit or schema ref
FILE uploads

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.

BQL · upload via multipart marker (raw HTTP shape)
// 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
Numeric conversions

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.
BQL · compose and evolve kinds
// 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"] }] }
BQL · inherit fields and roles from a parent
{
  "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.

BQL · built-in + custom field validations
{
  "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" }
}
BQL · computed on read + editable default
{
  "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'"
        }
      }
    }
  }
}
What is enforced
  • Content types like EMAIL already run system validation
  • Custom validators use $value and can target create and/or update
  • severity: "error" blocks the mutation
Computed behavior
  • computeType: "computed" runs on every query and rejects writes
  • computeType: "editable" + $js acts 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.

BQL · transform + validate + effect
{
  "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"
    }
  }
}
BQL · remote transform hook
{
  "kinds": {
    "Person": {
      "dataFields": {
        "name": { "valueType": "TEXT" }
      }
    }
  },
  "hooks": {
    "Person.upcase": {
      "type": "unit.transform",
      "target": {
        "ops": ["create", "update"],
        "$kinds": { "$any": ["Person"] }
      },
      "remote": "upcase_name@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
    }
  }
}
How hooks run
  • 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 WASM remote module
Current limits
  • Hooks mutate $this; cross-unit cascades are not supported yet
  • unit.effect runs post-commit; blocking checks belong in unit.validate
  • Transform hooks on link are 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.

BQL · observe unlink events in an update hook
{
  "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" and targetRoles project 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.

BQL · direct binary relation: Author ↔ Book
// 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
        }
      }
    }
  }
}
BQL · intermediate relation: Company ↔ Membership ↔ Employee
{
  "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" }
      }
    }
  }
}
BQL · query relation data fields
{
  "$kinds": "Company",
  "$fields": [
    "name",
    {
      "$expand": "memberships",
      "$fields": [
        "title",
        "startDate",
        { "$expand": "employee", "$fields": ["name", "email"] }
      ]
    }
  ]
}
ONE
0 or 1 connection. Add required for exactly 1.
MANY
0..N connections. Supports min and max bounds.
INTERVAL
Data-field only. Role and link fields reject it. Values use the { "#intervals": ... } input shape. Results carry no cast key; their shape follows the field's intervalFormat.
Polymorphic roles

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.

BQL · polymorphic role + projected tunnel
{
  "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 & self relations

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.

BQL · symmetric friendship + self-reference allowed on Document
{
  "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.

BQL · definitions/import · full schema (additive only)
// 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" }
}
BQL · definitions/export · current definitions for the subspace
// 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.

BQL · namespace bundle export → detached 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.

BQL · userKind + permissions + portal access
{
  "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 }"
          }
        }
      }
    }
  }
}
BQL · definitions/query · any definition type
// 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" } } }
BQL · definitions/mutate · incremental changes
// 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.

BQL · all users
// Returns all User units (array)
{ "$kinds": "User", "$fields": "*" }
BQL · by ID
// Returns one unit (object or null)
{ "$id": "abc123", "$fields": "*" }
BQL · specific fields + sort + pagination
{
  "$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.

BQL · cursor pagination · replay $nextCursor
// 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
#

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.

BQL · field selection modes
// 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" }
BQL · $definedOn · disambiguate a composed field name
{
  "$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.

BQL · expand with sibling relations
{
  "$kinds": "Post",
  "$fields": [
    "title",
    { "$expand": "author", "$fields": ["name", "email"] },
    { "$expand": "comments", "$fields": ["text"] }
  ]
}
BQL · nested expand with filter and sort
{
  "$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.

BQL · $as · rename a field in the response
{
  "$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.

BQL · $expandArc · connections with timestamps
{
  "$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.

BQL · $arcCreatedAt · filter + sort connections by creation time
{
  "$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, $arcCreatedAt must 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.

BQL · root aggregate without grouping
{
  "$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.

BQL · group by with multiple aggregations
{
  "$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" } }
  ]
}
BQL · group by arc identity and normalized expression
{
  "$kinds": "Order",
  "$groupBy": {
    "$by": [
      "customer",
      { "$js": "status.trim().toLowerCase()", "$as": "normalizedStatus" }
    ]
  },
  "$aggFields": [
    { "%count": { "$agg": "COUNT" } }
  ]
}
BQL · aggregation inside $expand
{
  "$kinds": "Team",
  "$fields": [
    "name",
    {
      "$expand": "members",
      "$groupBy": ["position"],
      "$aggFields": [
        { "%count": { "$agg": "COUNT" } }
      ]
    }
  ]
}
COUNT
SUM
AVG
MIN
MAX
MEDIAN
LIST
SET
FIRST
LAST
EVERY
SOME
Putting it all together
BQL · polymorphic search + computed fields
{
  "$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.

BQL · create, update, delete
// 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 } }
create
$setKinds without $id
update
$id with data fields
delete
$id with no data fields
#

Upsert

$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.

BQL · upsert by $id
// 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"
}
BQL · upsert by $filter
// 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"
}
Valid identity selectors
Use $id for direct identity, or $filter for a 0-or-1 match lookup.
Errors
$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 references — capture a created unit and link it later in the same batch
BQL · $var batch — create two units and join them
[
  { "$var": "_:user", "$setKinds": ["User"], "name": "Alice" },
  { "$var": "_:acct", "$setKinds": ["Account"], "provider": "github" },
  { "$setKinds": ["UserAccount"], "user": "_:user", "account": "_:acct" }
]
BQL · nested tree — embed child units directly inside the parent field
{
  "$setKinds": ["UserAccount"],
  "user": { "$setKinds": ["User"], "name": "Alice" },
  "account": { "$setKinds": ["Account"], "provider": "github" }
}
Deep nesting (3 levels)
BQL · deep nesting (3 levels)
// User → Tag → Group → Colors, all created atomically
{
  "$setKinds": ["User"],
  "tags": [{
    "$setKinds": ["Tag"],
    "group": {
      "$setKinds": ["Group"],
      "colors": [
        { "$setKinds": ["Color"], "name": "Red" },
        { "$setKinds": ["Color"], "name": "Blue" }
      ]
    }
  }]
}
Mixed operations in one batch
BQL · mixed operations in one batch
// 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.

BQL · public ids on kinds with idField
// 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"
    }
  ]
}
BQL · export → import (arc topology preserved)
// 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.
In-batch references
Use exported $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.
SSE progress
Add 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.

BQL · $for · array or $range source
// 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" }]
}
BQL · $if · conditional inclusion
// 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.

BQL · arc operations
// 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"] }
BQL · link by filter
{
  "$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.

BQL · tunnel query and projected update
// 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"
  }
}
BQL · tunnel relation-tree create
{
  "$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.

BQL · create · topological evaluation (tax depends on subtotal, total depends on both)
{
  "$setKinds": ["Invoice"],
  "subtotal": 100,
  "tax": { "$js": "subtotal * 0.21" },
  "total": { "$js": "subtotal + tax" }
}
BQL · update · stored fields + self-increment
{
  "$id": "line1",
  "total": { "$js": "price * qty" },
  "count": { "$js": "count + 1" }
}
BQL · string transforms and slugs
{
  "$setKinds": ["Post"],
  "title": "Hello World",
  "slug": { "$js": "title.toLowerCase().replaceAll(' ', '-')" }
}
BQL · typescript expression · types erased before execution
{
  "$setKinds": ["Invoice"],
  "subtotal": 100,
  "total": { "$ts": "const rate: number = 0.21; subtotal * (1 + rate)" }
}
BQL · full workflow · query + $var + $js
// 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 %.

BQL · virtual fields in queries
{
  "$kinds": "Order",
  "$fields": [
    "name", "price", "quantity",
    { "%line_total": { "$js": "price * quantity" } },
    { "%tax_label": { "$js": "'Tax: ' + (price * 0.21).toFixed(2)" } }
  ]
}
Typed literals

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.

BQL · canonical #-prefixed object form
{
  "$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.

Interval values

An INTERVAL field stores a set of ranges. Probe with $contains (full containment of a point or sub-set) and $intersects (any overlap).

BQL · interval write + queries
// 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" }]]
      }
    }
  }
}
Native expressions

$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.

BQL · native @text.concat virtual field
{
  "$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.

BQL · ONE · single $id
{
  "data": {
    "$id": "abc123",
    "$kinds": ["User"],
    "name": "Alice",
    "email": "[email protected]"
  },
  "outcome": "completed"
}
BQL · MANY · by $kinds
{
  "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.

BQL · $explain · plan + timing in the response
{ "$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.

$id
Public ID (custom idField or internal ULID)
$iid
Internal ULID (22-char base62)
$kinds
All kind names (always an array)
$version
Mutation counter
$createdAt
ISO-8601 creation timestamp
$updatedAt
ISO-8601 last mutation timestamp
$meta
Shorthand: expands to all of the above
$score
BM25 relevance score, only with $search (request explicitly)
$history
Per-unit event stream (inline via $fields; "$history" = IDs, { $expand: "$history" } = entries)
#

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).

  • issues is one flat, severity-sorted diagnostic list
  • severity is error, warning, or info
  • phase is 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.

BQL · empty-result warning (HTTP 200)
{
  "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 } }
}
BQL · rejected response (HTTP 422)
{
  "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.

UNKNOWN_KIND
Referenced a kind that is not defined (carries a typo suggestion)
BATCH_TOO_LARGE
Mutation batch exceeded maxBatchSize (chunk via data_import / namespace import)
AMBIGUOUS_FIELD_ACROSS_KINDS
A written field resolves to conflicting definitions across the unit’s kinds
UNKNOWN_OPTS_KEY
Unknown option key; carries data: { unknown, allowed }
PERMISSION_DENIED
Authenticated token lacks the route permission
SIGN_IN_REQUIRED
Portal admission needs an app-user session
ACCESS_FORBIDDEN
Portal access rule denied the live subject
SECRETS_KEK_UNAVAILABLE
Secret ops require BLITZSTORE_SECRET_ENV_VAULT_KEYRING
NOT_IMPLEMENTED
Reserved opt not enforced yet: parallel, limits.maxMemoryBytes, limits.maxRegexComplexity
SUBSPACE_LIMIT_EXCEEDED
Creating the subspace would exceed the namespace subspace limit

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.

Need more details? Join the community on Discord.