BlitzGraph beta · puede haber interrupciones puntuales
Documentación

Construye con BQL

BlitzStore es una base de datos de grafos polimórfica. Define tus datos como , conéctalos con y consúltalo todo, incluidas las relaciones anidadas, en una sola petición JSON.

JSON de entrada, JSON de salida
Sin cadenas SQL ni cadenas de ORM
Recorrido de grafo
Expande relaciones sin N+1
Mutaciones atómicas
Operaciones por lotes, todo o nada

Conectar

Configura el acceso HTTP, elige la familia de tokens adecuada y mantén la auth de sesión de la aplicación separada de la auth del servidor de datos.

#

Autenticarse

El servidor de datos de BlitzStore solo acepta Authorization: Bearer <token>. No existe una cabecera API-Key. Cuatro familias de tokens usan ese mismo transporte:

  • bzt_*: grant tokens de operador con alcance para clientes (alcance de servidor, de base de datos o de namespace). Acúñalos desde el plano de control de BlitzGraph y úsalos en las llamadas al plano de datos (/query, /mutate, /admin).
  • bzu_*: credenciales de sujeto para los usuarios finales de un portal, ligadas a una unidad dentro de un namespace y confinadas a la superficie de aplicación para la que se acuñaron. La API en crudo las rechaza con SURFACE_CONFINED.
  • bzi_*: claves internas por instancia, sin restricciones, reservadas para una recuperación de emergencia explícita.
  • bzc_*: capacidades de servicio de 60 segundos ligadas a una instancia, a una acción HTTP y a un alcance de base de datos o de namespace. Algunas rutas internas de servicio aceptan la acción correspondiente; la administración de claves y de blobs sigue siendo exclusiva de bzi_*.
BQL · primera llamada con un 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}}'

La rotación en caliente de las claves bzi_* se hace por /server/keys/upsert; los grant tokens nuevos se proyectan por /server/grants/upsert.

El límite de Better Auth

Los bearer access tokens de Better Auth no autentican directamente contra el servidor de datos. Autentican sesiones de personas o de agentes en rutas de aplicación de BlitzGraph como POST /agents/build-now, que a su vez emiten o reutilizan un grant de operador bzt_* con alcance para la cuenta de BlitzGraph que ha iniciado sesión. El servidor de datos solo llega a ver el bzt_* acuñado.

El usuario final de un portal va por otro carril: su sesión la acuña el servidor de datos como una credencial de sujeto bzu_*, confinada a la única superficie de aplicación para la que se emitió y rechazada en la API en crudo.

Vinculación del destino MCP

El flujo de conexión OAuth de MCP autoriza el grant de conexión emitido contra un Space, no contra un namespace o un servidor fijos. Cada llamada resuelve en vivo la ubicación actual del Space, así que mover un Space nunca invalida un grant existente. Las herramientas heredan el destino de namespace seleccionado; usa opts.defaultSubspace cuando una llamada concreta deba apuntar a otro subspace por defecto.

Plataforma

Los alcances de almacenamiento aíslan los datos; las consultas en vivo los mantienen sincronizados; los workflows duraderos orquestan el trabajo; los límites efectivos explican la capacidad; y BlitzStudio opera todas esas superficies.

#

Namespaces y subspaces

Un es el límite de aislamiento de nivel superior dentro de una base de datos. Cada uno tiene su propio esquema, sus datos y sus índices. Un es otro límite de aislamiento dentro de ese namespace: los datos, el esquema, los portales y los índices quedan acotados a él. Los usuarios del plan gratuito tienen un namespace y pueden crear varios subspaces (por ejemplo main, archive, drafts).

BQL · apuntar a un subspace en consultas y mutaciones
// 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 · crear y gestionar subspaces (API de administración)
// POST /admin: create a subspace
{ "admin": { "$resource": "subspace", "$op": "create", "$rid": "ss:archive", "storage": "memory" } }

// POST /admin: list subspaces
{ "admin": { "$resource": "subspace", "$op": "query" } }

Los datos están totalmente aislados entre subspaces. Una consulta sobre "main" nunca ve datos de "archive". Cada subspace puede tener su propio esquema, definido con más opts.defaultSubspace, o con ns.defaultSubspace("name").schema.import() en el SDK.

Para transferencias autoservicio con alcance de namespace, usa las rutas /namespace/bundle/* y /subspace/bundle/*. Emiten bundles .bzg estrictos y ejecutan las importaciones en segundo plano, así que puedes volver a subir el mismo bundle para reanudar una importación interrumpida que coincida.

El $history por unidad es ilimitado por defecto. Fija history.retentionDays o history.maxEventsPerUnit en la configuración del namespace para acotarlo; un barrido en segundo plano elimina los eventos que superan la ventana de retención o el tope, y las lecturas de $history solo ven las entradas que sobreviven.

#

Consultas en vivo

Una mantiene un resultado BQL ordenado sincronizado por SSE. El carril en crudo POST /query/live es para grants con alcance y claves internas. Las sesiones de de un portal solo se suscriben mediante una operación de consulta con nombre y validada en /_blitz/ops/query/{op}/live, así que un navegador nunca obtiene la capacidad de consulta en crudo.

BQL · suscripción con el SDK en crudo
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 · hooks de React y de portal
// 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' })
LiveFrame atómico
Las instantáneas, los parches, los latidos y los frames terminales comparten un único contrato generado. Un parche es todo o nada.
Reanudación opaca
Reconecta con el último checkpoint aplicado. Nunca analices, edites ni guardes un checkpoint antes de que se aplique su frame.
Autoridad que falla en modo cerrado
La caducidad, la revocación, los cambios en el nivel mínimo de autorización del sujeto y los cambios de política de la operación se revalidan antes de entregar contenido protegido.

Un LIVE_CONTINUITY_GAP reintentable significa que el servidor ha acotado un hueco y lo está reparando. LIVE_RESUME_UNAVAILABLE es estable: un operador debe configurar BLITZSTORE_SECRET_LIVE_RESUME_KEYRING antes de que la reanudación esté disponible. El store del cliente espacia las reconexiones y recurre a una instantánea autorizada nueva cuando ya no se puede usar una repetición retenida.

#

Acciones y workflows

Disponible en BlitzGraph 0.61.0. Una Action es un paso tipado y reutilizable; un es una máquina de estados duradera que orquesta Actions, BQL, esperas, señales, workflows anidados y fan-out acotado. Las definiciones pasan por el linter antes de persistirse, y cada ejecución fija su clausura ejecutable y el digest del programa.

BQL · action TypeScript en línea
{ $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 · action WASM protegida
{ $op: 'create', $type: 'action', name: 'protectedScore',
  type: 'pure', mode: 'wasm',
  module: 'scorer@sha256:<64 lowercase hex>',
  inputSchema: { type: 'object' },
  outputSchema: { type: 'number' }
}

El código en línea acepta $js, $ts o $code explícito. TypeScript se guarda tal como se escribió, pero el servidor rechaza imports, módulos, JSX, decoradores, enums, namespaces y demás sintaxis que genere runtime antes de que una definición persista. Los diagnósticos de Studio usan el mismo manifiesto generado de perfil restringido; el linter del servidor sigue siendo la autoridad.

BQL · ciclo de vida de módulos con alcance de namespace
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 solo está disponible para pure×wasm. Los componentes exportan process(string) → result<string, string>, no tienen imports del host y se ejecutan con memoria, salida, errores, logs, bytes de caché y plazos acotados. El servidor fija y valida la ABI de WASM en el programa de workflow duradero; las definiciones públicas de Action no aceptan un campo abiVersion.

BQL · ejecutar una vez, inspeccionar o transmitir la tarea duradera
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)
}
Control de flujo duradero
Las esperas, las señales, los reintentos, las llamadas anidadas, la concurrencia por clave y la compensación de saga sobreviven a los reinicios de proceso.
Map acotado
Recorre una Action o un Workflow con nombre con concurrencia acotada, resultados ordenados, reintentos y comportamiento de lanzar o recopilar.
Recuperación inspeccionable
Los eventos tipados exponen las oleadas de hijos, los joins, los reintentos, la compensación, el workflow propietario y la correlación de trazas W3C.

La recuperación tras un fallo reanuda toda la pila de llamadas y el cursor de compensación pendiente. La pertenencia de los hijos mapeados se registra antes del despacho, así que los joins y las cancelaciones no pierden hijos tras un relevo de worker. MCP expone el mismo ciclo de vida con workflows.run, workflows.runStatus y las herramientas de tareas y eventos.

#

Límites efectivos

Un es el valor que el servidor aplica de verdad una vez resueltos los valores por defecto, la configuración, las anulaciones de entorno y el alcance de la petición. El catálogo y los detalles de rechazo son proyecciones de ese mismo objeto, así que los valores documentados y los aplicados no pueden divergir.

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
Valor y unidad exactos
Los valores configurados, efectivos y observados usan unidades generadas e identidades de límite estables.
Acotado por autoridad
Quien llama con alcance de base de datos o de namespace solo ve su alcance efectivo exacto; el acceso de AppUser y de invitados se deniega.
Rechazo accionable
retryAfterMs junto con retryLater o reduceRequest le indica al cliente cómo responder sin analizar mensajes.
#

BlitzStudio

BlitzStudio es la interfaz visual integrada para gestionar tus datos. Se conecta a cualquier instancia de BlitzStore y te da un espacio de trabajo completo con edición de esquema, exploración de datos y una consola de consultas en vivo.

BlitzSheet
Explorador de datos estilo hoja de cálculo. Consulta, edita, crea y borra unidades en una cuadrícula. Filtra y ordena por cualquier campo.
QueryStudio
Consola BQL en vivo. Escribe consultas y mutaciones y ve los resultados en tiempo real como un árbol JSON.
Editor de esquema
Gestión visual del esquema. Crea clases, define campos y configura roles y relaciones.
Apps (portales)
Despliega frontends propios conectados a tus datos de BlitzStore. Cada app tiene su propia ruta, servida desde tu namespace.
Transferencia de namespace
Exporta artefactos almacenados o descarga bundles .bzg, importa en un destino nuevo y consulta el estado de la transferencia sin salir de Studio.
Env vault
Gestiona las constantes del namespace y los secretos sellados de solo escritura desde la superficie tipada de administración de env.

Esquema

Define tu modelo de datos con , campos, , validaciones, campos calculados y hooks de mutación. Importa tu una vez y el mismo modelo alimenta /query, /mutate y BlitzStudio.

#

Estructura de la base de datos

BlitzStore separa los (tus unidades y conexiones) de las (el y los ).

data ── unidades, arcos, índices
definitions
schema ── kinds, dataFields, roleFields, linkFields
portals ── apps, páginas, layouts, componentes
  • POST /query y POST /mutate operan sobre los datos
  • POST /definitions/import, /definitions/query y /definitions/mutate operan sobre las definiciones (mira )
  • POST /admin usa JSON de administración en crudo, no un sobre { body, opts }
  • POST /data/import devuelve JSON por defecto; añade Accept: text/event-stream para recibir el progreso por SSE
#

Clases y campos

  • Un es una definición de esquema (como una clase o una tabla)
  • Una es una instancia almacenada de una o varias clases
  • Las clases tienen tres tipos de campo:
    • contiene valores, validaciones y comportamiento de cálculo
    • define los puntos de conexión (mira )
    • es un atajo para recorrer desde el otro lado
  • El comportamiento de mutación que abarca varios campos va en los a nivel de clase
BQL · definición de esquema
{
  "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
Cadenas
INTEGER
Números enteros (i64)
DECIMAL
Base 10 exacta (dinero)
FLOAT
IEEE 754 f64 (ML, ciencia)
PERCENTAGE
Respaldado por decimal, 0–1
CURRENCY
Dinero: importe + ISO 4217
BOOLEAN
true / false
DATE
YYYY-MM-DD
DATETIME
ISO con zona horaria (obligatoria)
TIME
HH:MM:SS.SSS
DURATION
Combina w/d/h/m/s
INTERVAL
Conjunto de rangos (#intervals al escribir, intervalFormat al leer)
EMAIL
Con validación
URL
Con validación
FILE
Subida con el marcador #file
RICH_TEXT
Carga de texto enriquecido
COLOR
Valor de color
PHONE
Texto de teléfono
PASSWORD
Texto secreto
JSON
Objetos anidados
FLEX
Cualquier tipo
ID
Identificador
REF
Referencia a unidad o a esquema
Subidas de FILE

Un campo FILE almacena un blob más su metainformación. El cliente de TS detecta automáticamente los valores File / Blob en mutate() y cambia a multipart; el cuerpo JSON lleva un marcador { "#file": "<key>" } que el servidor asocia con la parte con ese nombre. Al leer, el valor se materializa como FileValue con filename / mime / size / url / thumbnail_url.

BQL · subida con marcador multipart (forma HTTP en crudo)
// 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
Conversiones numéricas

Cambiar el valueType de un campo entre INTEGER, DECIMAL, FLOAT y PERCENTAGE funciona. La mutación de esquema responde de inmediato; una tarea en segundo plano reescribe las filas almacenadas según la numericPolicy del campo: rejectOnLoss (por defecto), round, truncate o allowPrecisionLoss. NaN y ±Inf se rechazan siempre.

#

Polimorfismo y herencia

  • Polimorfismo. Una puede tener varias a la vez y hacerlas evolucionar con el tiempo. Un User puede ganar o perder Admin sin volver a crearse.
  • Herencia. Una clase puede declarar extends; hereda los campos y los roles de esa clase padre, y las consultas $kinds: "Parent" coinciden con todos sus descendientes por defecto.
BQL · componer y hacer evolucionar clases
// 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 · heredar campos y roles de una clase padre
{
  "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 }
#

Validaciones y campos calculados

Las reglas de campo viven junto a cada . Usa los validadores integrados para comprobar tipos y rangos, validadores $js propios para las reglas de negocio y campos calculados cuando un valor deba derivarse en lugar de almacenarse. Los campos calculados también pueden leer valores meta como $id o $createdAt.

BQL · validaciones de campo integradas y propias
{
  "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 · calculado al leer y valor por defecto editable
{
  "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'"
        }
      }
    }
  }
}
Qué se aplica
  • Los tipos de contenido como EMAIL ya ejecutan la validación del sistema
  • Los validadores propios usan $value y pueden apuntar a create, a update o a ambos
  • severity: "error" bloquea la mutación
Comportamiento de los campos calculados
  • computeType: "computed" se ejecuta en cada consulta y rechaza las escrituras
  • computeType: "editable" + $js actúa como valor por defecto al crear, que luego se puede sobrescribir
  • Los campos calculados pueden referirse a $id, $iid, $version, $createdAt, $updatedAt y $kinds
#

Hooks

Los hooks viven en la parte alta del esquema, en un mapa schema.hooks indexado por nombre, y apuntan a Units mediante { ops, $kinds, $filter }. Cada hook declara su categoría con type (unit.validate, unit.transform o unit.effect) y se ejecuta dentro del pipeline de mutación cuando su target coincide con el borrador de la Unit. Úsalos cuando una escritura deba derivar campos, validar el borrador final combinando varios campos o lanzar efectos secundarios después del commit.

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 · hook de transformación remoto
{
  "kinds": {
    "Person": {
      "dataFields": {
        "name": { "valueType": "TEXT" }
      }
    }
  },
  "hooks": {
    "Person.upcase": {
      "type": "unit.transform",
      "target": {
        "ops": ["create", "update"],
        "$kinds": { "$any": ["Person"] }
      },
      "remote": "upcase_name@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
    }
  }
}
Cómo se ejecutan los hooks
  • El orden es transform → validate → effect
  • Los hooks de validación ven el estado final de la transacción y también pueden inspeccionar $input
  • Los hooks que apuntan a clases ancestras pueden incluir descendientes con $descendants
  • Los hooks de transformación pueden devolver parches en línea o con un return explícito
  • El cuerpo de un hook acepta $js, $ts (con los tipos borrados) o un módulo WASM remote fijado
Límites actuales
  • Los hooks mutan $this; las cascadas entre unidades todavía no están soportadas
  • unit.effect se ejecuta después del commit; las comprobaciones que bloquean van en unit.validate
  • Los hooks de transformación sobre link todavía no están implementados
  • Los bucles de transformación que no convergen fallan por la protección de profundidad máxima

Cuando se borra una unidad y esa ruptura corta sus relaciones, cada ruptura llega a los hooks de update de la unidad superviviente como un evento unlink en $delta.arcs. Ese mismo flujo aparece también en $history y en la salida normalizada de la mutación, así que un superviviente puede reaccionar a perder una conexión.

BQL · observar eventos unlink en un hook de update
{
  "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') };"
    }
  }
}
#

Relaciones

  • Un vive en el lado de la relación y define quién puede conectarse y cuántos ()
  • Un vive en el lado del jugador y nombra la clase de relación más el rol que juega en ella
  • Las también son clases, así que pueden llevar sus propios campos de datos y consultarse directamente
  • target: "relation" devuelve las unidades de relación; target: "role" y targetRoles proyectan hasta el extremo o los extremos opuestos

Hay dos formas que conviene recordar. Para una dependencia simple como libro → autor, pon el en un lado y deja el inverso como . Eso es una relación directa, no un túnel. La regla de Giulietta es solo una recomendación para elegir el propietario. Cuando la conexión necesita sus propios campos, crea una clase de relación intermedia y expón un enlace en crudo más un túnel proyectado en cada lado si eso mejora la ergonomía de las consultas.

BQL · relación binaria directa: 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 · relación intermedia: 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 · consultar los campos de datos de la relación
{
  "$kinds": "Company",
  "$fields": [
    "name",
    {
      "$expand": "memberships",
      "$fields": [
        "title",
        "startDate",
        { "$expand": "employee", "$fields": ["name", "email"] }
      ]
    }
  ]
}
ONE
0 o 1 conexión. Añade required para exactamente 1.
MANY
0..N conexiones. Admite límites min y max.
INTERVAL
Solo para campos de datos. Los campos de rol y de enlace lo rechazan. Los valores usan la forma de entrada { "#intervals": ... }. Los resultados no llevan clave de casting; su forma sigue el intervalFormat del campo.
Roles polimórficos

Un rol lo pueden jugar varias clases mediante playedBy: ["A", "B"]. Las creaciones anidadas deben indicar $setKinds para que el motor sepa qué clase crear; cuando playedBy tiene exactamente una clase, se infiere.

BQL · rol polimórfico + túnel proyectado
{
  "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" }
}
Relaciones simétricas y sobre sí mismas

symmetric: true en un campo de rol colapsa A↔B y B↔A en una sola arista: el duplicado se rechaza con SYMMETRIC_RELATION_EXISTS. Por defecto, cada rol rechaza que el mismo jugador aparezca en dos roles distintos de la misma unidad de relación (SELF_RELATION_FORBIDDEN); actívalo rol a rol con allowSelfRelation: true.

BQL · amistad simétrica + autorreferencia permitida en Document
{
  "kinds": {
    "Friendship": {
      "roleFields": {
        "friends": { "playedBy": ["User"], "cardinality": "MANY", "symmetric": true }
      }
    },
    "Reference": {
      "roleFields": {
        "from": { "playedBy": ["Document"], "allowSelfRelation": true },
        "to":   { "playedBy": ["Document"], "allowSelfRelation": true }
      }
    }
  }
}
#

Operaciones de esquema

Cuatro endpoints gestionan las en tiempo de ejecución: import, export, query y mutate. Import y export usan cuerpos JSON directos con opts.defaultSubspace. Query y mutate usan los sobres query / mutation.

Todo cuerpo de importación de definiciones (/definitions/import, /schema/import, /portals/import, /automations/import) debe llevar $definitionContract: lee la huella desde GET /health (campo $definitionContract) o deja que el SDK de JS la estampe automáticamente; un cuerpo sin ella se rechaza con 422.

BQL · definitions/import · esquema completo (solo aditivo)
// 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 · definiciones actuales del 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.

Los endpoints de definiciones de arriba solo mueven el esquema y los portales. Quien opera a nivel de store puede mover un namespace entero con sus artefactos almacenados (/namespace/export y /namespace/import). Los usuarios con alcance de namespace usan en su lugar las rutas de bundle .bzg: el bundle lleva la misma carga verificada, las importaciones corren en segundo plano y volver a subir el mismo bundle reanuda una importación interrumpida que coincida.

BQL · exportación de bundle de namespace → importación en segundo plano
// 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
bundle=@main.bzg

// → 202 { "importId": "01...", "resumed": false }
// Poll GET /admin/import-status until terminal.importId matches.

El viaje de ida y vuelta de los FILE lo controla include.files. En las rutas de bundle vale false por defecto para evitar salidas de datos accidentales; ponlo a true para llevar todos los blobs, o a un objeto { "perSubspace": { ... } } para campos FILE concretos. Las rutas de artefactos a nivel de store ponen include.files a true por defecto porque la transferencia se queda en el servidor.

La autorización de los usuarios de aplicación también se escribe en las definiciones. Una clase sujeto se marca con userKind y un campo de identidad de correo. Las clases protegidas usan reglas permissions evaluadas con $me. Los portales usan reglas access para la admisión de app, de página o de endpoint.

BQL · userKind + permissions + acceso al portal
{
  "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 <no-reply@example.com>"
          },
          "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 · cualquier tipo de definición
// 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 · cambios incrementales
// 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" }
}

Consultas

Cada consulta es un objeto JSON que se envía a POST /query. Los resultados vuelven como JSON y su forma es predecible a partir de tu consulta.

#

Fundamentos

Usa $kinds para seleccionar por clase, $id para traer una unidad concreta y $fields para controlar qué vuelve.

BQL · todos los usuarios
// Returns all User units (array)
{ "$kinds": "User", "$fields": "*" }
BQL · por ID
// Returns one unit (object or null)
{ "$id": "abc123", "$fields": "*" }
BQL · campos concretos + orden + paginación
{
  "$kinds": "User",
  "$fields": ["name", "email", "age"],
  "$sort": [{ "$by": "age", "$order": "desc" }],
  "$limit": 10,
  "$offset": 20
}

Para conjuntos de resultados grandes, prefiere $cursor antes que $offset. Pasa $cursor junto con $limit; la respuesta devuelve un token opaco meta.$nextCursor que reenvías tal cual como el siguiente $cursor. Si no aparece meta.$nextCursor, has llegado a la última página. Nunca analices ni construyas el token. La paginación por cursor requiere $limit, excluye $offset, solo vale en la consulta raíz y se rechaza con $groupBy y con agregados en la raíz.

BQL · paginación por cursor · reenviar $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
#

Proyecciones

$fields controla qué datos vuelven. Usa "*" para todos los campos, un array para campos concretos o $excludedFields para excluir. Los campos de arco en $fields devuelven los IDs sin expandir. Usa $expand para traer las unidades completas. Cuando varias clases compuestas definen el mismo nombre de campo de forma distinta, usa $definedOn para elegir la clase que lo define; una fila multiclase realmente ambigua devuelve un marcador $ambiguous en lugar de adivinar.

BQL · modos de selección de campos
// 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 · desambiguar un nombre de campo compuesto
{
  "$kinds": ["Bot", "User"],
  "$fields": [
    { "$fetch": "name", "$definedOn": "User", "$as": "userName" },
    { "$fetch": "name", "$definedOn": "Bot", "$as": "botName" }
  ]
}
#

Expandir relaciones

$expand recorre las relaciones e inserta las unidades conectadas. Sin N+1: BlitzStore agrupa todos los recorridos automáticamente. Puedes anidar $expand a cualquier profundidad, con sus propios $fields, $filter, $sort y $limit. Usa para incluir las marcas de tiempo de la conexión.

BQL · expandir con relaciones hermanas
{
  "$kinds": "Post",
  "$fields": [
    "title",
    { "$expand": "author", "$fields": ["name", "email"] },
    { "$expand": "comments", "$fields": ["text"] }
  ]
}
BQL · expansión anidada con filtro y orden
{
  "$kinds": "User",
  "$fields": [
    "name",
    {
      "$expand": "posts",
      "$filter": { "status": "published" },
      "$sort": [{ "$by": "title", "$order": "asc" }],
      "$limit": 5,
      "$fields": ["title", "status"]
    }
  ]
}

Usa $as para renombrar en la respuesta un campo expandido o virtual cuando el nombre del esquema no se lee bien en el cliente.

BQL · $as · renombrar un campo en la respuesta
{
  "$kinds": "User",
  "$fields": [
    { "$expand": "posts", "$as": "articles", "$fields": ["title"] }
  ]
}
#

Metadatos de arco

funciona como , pero envuelve cada resultado en un sobre de metadatos de con $arcCreatedAt (marca de tiempo ISO). Útil para ver cuándo se crearon las conexiones: amistades, membresías, trazas de auditoría.

BQL · $expandArc · conexiones con marcas de tiempo
{
  "$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" } }
// ]}

Dentro de también puedes filtrar y ordenar las relaciones por cuándo se creó cada conexión, usando $arcCreatedAt como clave de filtro y de orden. El predicado sobre la hora del arco se evalúa antes de traer las unidades conectadas, así que las conexiones que no coinciden se descartan pronto.

BQL · $arcCreatedAt · filtrar y ordenar conexiones por hora de creación
{
  "$kinds": "Team",
  "$id": "t1",
  "$fields": [
    {
      "$expandArc": "members",
      "$filter": { "$arcCreatedAt": { "$gte": { "#datetime": "2026-01-01T00:00:00.000Z" } } },
      "$sort": "-$arcCreatedAt",
      "$limit": 10
    }
  ]
}
  • Solo operadores de comparación: $gte, $gt, $lte, $lt, $eq, $neq
  • Como filtro, $arcCreatedAt debe ser una condición de nivel superior (o estar dentro de un $and de nivel superior)
  • Como clave de orden debe ser la clave principal (la primera)
  • Solo es válido dentro de , nunca en un normal
#

Agregaciones

$groupBy agrupa los resultados por valores de campo, pero no hace falta para obtener una única fila resumen en la raíz. Agrega con operadores $agg como COUNT, SUM, AVG, MIN, MAX y más. Funciona en el nivel raíz y anidado dentro de $expand.

BQL · agregado en la raíz sin agrupar
{
  "$kinds": "Order",
  "$aggFields": [
    { "%count": { "$agg": "COUNT" } },
    { "%total": { "$agg": "SUM", "$field": "amount" } },
    { "%avg": { "$agg": "AVG", "$field": "amount" } }
  ]
}

$groupBy emite por sí solo las filas con las claves de grupo distintas; añade $aggFields cuando esas filas necesiten métricas. $groupBy.$filter es el HAVING, y el $sort, el $limit y el $offset del objeto $groupBy ordenan y paginan los grupos. El $sort / $limit / $offset a nivel de nodo acota las unidades candidatas antes de agrupar. Las claves de grupo pueden ser campos escalares almacenados o calculados, identidades de arco ONE, o expresiones $js/$ts/$code/$expr escritas por ti con un nombre $as. Las claves con valores MANY se rechazan de forma explícita en lugar de crear grupos ambiguos.

BQL · agrupar con varias agregaciones
{
  "$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 · agrupar por identidad de arco y por expresión normalizada
{
  "$kinds": "Order",
  "$groupBy": {
    "$by": [
      "customer",
      { "$js": "status.trim().toLowerCase()", "$as": "normalizedStatus" }
    ]
  },
  "$aggFields": [
    { "%count": { "$agg": "COUNT" } }
  ]
}
BQL · agregación dentro de $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
Todo junto
BQL · búsqueda polimórfica + campos calculados
{
  "$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" } }
      ]
    }
  ]
}

Mutaciones

Envía las mutaciones a POST /mutate. Las operaciones se infieren de la forma de tu JSON, o fija $op de forma explícita.

#

Crear, actualizar y borrar

La operación se infiere de tu entrada: $setKinds sin $id crea, $id con campos actualiza y $id a solas borra.

BQL · crear, actualizar, borrar
// Create: $setKinds without $id
{ "$setKinds": ["User"], "name": "Alice", "email": "alice@test.com" }

// Update: $id with fields
{ "$id": "user_abc123", "email": "new@test.com" }

// Delete: $id without fields
{ "$id": "user_abc123" }

// Bulk delete: with $filter
{ "$op": "delete", "$kinds": "Task", "$filter": { "done": true } }
create
$setKinds sin $id
update
$id con campos de datos
delete
$id sin campos de datos
#

Upsert

$op: "upsert" actualiza cuando la identidad resuelve una unidad existente y crea cuando no. Como puede crear, $setKinds es obligatorio. La identidad puede venir de $id o de $filter, pero nunca de los dos.

BQL · upsert por $id
// Kind has idField: "email"
// First call creates
{
  "$op": "upsert",
  "$id": "alice@test.com",
  "$setKinds": ["User"],
  "email": "alice@test.com",
  "name": "Alice"
}

// Second call updates same unit
{
  "$op": "upsert",
  "$id": "alice@test.com",
  "$setKinds": ["User"],
  "name": "Alice V2"
}
BQL · upsert por $filter
// 0 matches -> create
{
  "$op": "upsert",
  "$setKinds": ["User"],
  "$filter": { "email": "bob@test.com" },
  "email": "bob@test.com",
  "name": "Bob"
}

// 1 match -> update
{
  "$op": "upsert",
  "$setKinds": ["User"],
  "$filter": { "email": "bob@test.com" },
  "name": "Bob V2"
}
Selectores de identidad válidos
Usa $id para la identidad directa, o $filter para una búsqueda que coincida con 0 o 1 unidad.
Errores
$id + $filter no es válido. Un $filter que coincida con 2 o más unidades tampoco lo es.
#

Operaciones por lotes

Pasa un array para hacer mutaciones por lotes atómicas. O tienen éxito todas o se deshacen todas. Puedes mezclar creaciones, actualizaciones y borrados en el mismo lote. Usa $var para referenciar unidades entre operaciones, o anida las unidades hijas directamente dentro de su padre.

Referencias $var: captura una unidad creada y enlázala después en el mismo lote
BQL · lote con $var: crear dos unidades y unirlas
[
  { "$var": "_:user", "$setKinds": ["User"], "name": "Alice" },
  { "$var": "_:acct", "$setKinds": ["Account"], "provider": "github" },
  { "$setKinds": ["UserAccount"], "user": "_:user", "account": "_:acct" }
]
BQL · árbol anidado: incrustar las unidades hijas en el campo del padre
{
  "$setKinds": ["UserAccount"],
  "user": { "$setKinds": ["User"], "name": "Alice" },
  "account": { "$setKinds": ["Account"], "provider": "github" }
}
Anidamiento profundo (3 niveles)
BQL · anidamiento profundo (3 niveles)
// User → Tag → Group → Colors, all created atomically
{
  "$setKinds": ["User"],
  "tags": [{
    "$setKinds": ["Tag"],
    "group": {
      "$setKinds": ["Group"],
      "colors": [
        { "$setKinds": ["Color"], "name": "Red" },
        { "$setKinds": ["Color"], "name": "Blue" }
      ]
    }
  }]
}
Operaciones mezcladas en un lote
BQL · operaciones mezcladas en un lote
// Create + update in the same atomic batch
[
  { "$setKinds": ["User"], "name": "Bob", "status": "new" },
  { "$id": "existing_user_id", "status": "updated" }
]
#

Importación de datos

POST /data/import es un carril rápido de solo creación para sembrar conjuntos de datos. Los elementos sin un $op explícito toman create por defecto; cualquier otro $op se rechaza. El constructor ordena topológicamente las referencias $var y trocea los arrays grandes, así que una sola carga puede llevar un conjunto de datos entero y respetar el orden de padres antes que hijos.

BQL · ids públicos en clases con 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 · exportar → importar (se conserva la topología de arcos)
// 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 en la importación
El $id de un objeto de creación importado resuelve las referencias de arco dentro de la misma importación y se descarta antes de crear; envía los valores de idField como campos de datos normales.
Referencias dentro del lote
Usa los valores $id exportados o $var: "_:<name>" en el padre y referéncialo desde los hijos. El constructor reserva los ULID por adelantado y ordena topológicamente. Los ULID internos nunca los elige quien llama.
Progreso por SSE
Añade Accept: text/event-stream a POST /data/import para recibir eventos de progreso por trozos; omítelo para obtener un JSON de una sola vez. opts.batchSize ajusta el troceado en el servidor (5000 por defecto): no lo dividas en el cliente.
#

Límites de tamaño de lote

db.mutate() limita una sola llamada a 10.000 operaciones por defecto; definition_mutate limita a 500 elementos. Cuenta cada nodo de mutación raíz o anidado (create, update, upsert, delete, query), más cada destino explícito de arco link / unlink / replace. Una operación masiva acotada por filtro cuenta como una, coincida con las unidades que coincida: el tope acota el tamaño del lote de entrada, no el número de coincidencias. Los nodos se cuentan después de expandir $for / $if.

Superar el tope devuelve BATCH_TOO_LARGE, que dirige a quien envía datos hacia data_import por trozos o la importación de namespace, y a quien envía definiciones hacia lotes más pequeños. Sube el tope por llamada (hasta el tope duro del servidor) o por namespace (mutation.maxBatchSize / definitionMutation.maxBatchSize, solo al alza: la configuración del namespace no puede bajar el valor por defecto del servidor). maxTotalGeneratedItems (100.000) sigue siendo el techo absoluto de expansión de $for aunque subas maxBatchSize.

#

Control de flujo

$for genera elementos de mutación a partir de un bucle; $if los incluye de forma condicional. El control de flujo se expande antes de que corra el motor, así que el lote se mantiene plano y atómico. Ambos se pueden anidar y mezclar con elementos normales.

BQL · $for · origen de array o de $range
// 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 · inclusión condicional
// With optional else branch
{
  "$if": { "$js": "1 > 2" },
  "$then": [{ "$setKinds": ["Status"], "value": "then" }],
  "$else": [{ "$setKinds": ["Status"], "value": "else" }]
}

Escribe {_:var} dentro de las cadenas para interpolar la variable del bucle. El total de iteraciones está acotado por los límites de consulta para evitar bucles descontrolados.

#

Operaciones de grafo

Gestiona las conexiones de con $op dentro de los campos de arco (mira para las marcas de tiempo). link añade conexiones, unlink las quita y replace las fija exactamente. La asignación directa (azúcar de DX) es la forma abreviada de replace.

BQL · operaciones de arco
// 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 · enlazar por filtro
{
  "$id": "book1",
  "authors": {
    "$op": "link",
    "$kinds": "User",
    "$filter": { "role": "dev" }
  }
}

En los linkField con target: "role", las escrituras con forma de extremo directo se rechazan. Usa una actualización proyectada del extremo para las unidades relacionadas existentes, o crea el árbol de relación de forma explícita.

BQL · consulta por túnel y actualización proyectada
// 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 · creación del árbol de relación por túnel
{
  "$id": "candidate_123",
  "interviewers": {
    "$setKinds": ["Interview"],
    "date": "2026-04-11T09:00:00Z",
    "interviewer": {
      "$setKinds": ["Interviewer"],
      "name": "Dana",
      "department": "Platform"
    }
  }
}
#

Expresiones

Usa $js para expresiones JavaScript en línea o $ts para TypeScript (los tipos se borran antes de ejecutar; la forma explícita es $code: { $lang, $body }). Funciona en las escrituras de campos de datos al crear y al actualizar (incluidos el upsert y las actualizaciones de arco acotadas). Al crear solo ve los campos hermanos; al actualizar ve la fila almacenada además de los hermanos, en orden topológico. La autorreferencia lee el valor almacenado antes de la escritura. Las variables de lote usan _:varname. Las dependencias circulares entre hermanos se rechazan. Los hooks ven la expresión tal como se escribió, no el valor evaluado. Comprueba la ausencia con isNull(x); los locales DECIMAL son objetos: usa decimal().

Una expresión que devuelve null limpia su destino, igual que un null literal. Los fallos en ejecución hacen fallar la mutación por defecto; al actualizar, onExprError: "skip" deja sin escribir solo el campo que falla y emite un aviso. Los objetos con forma de código dentro de los valores o arrays de una operación JSON son JSON literal, no expresiones ejecutables.

BQL · crear · evaluación topológica (tax depende de subtotal, total depende de ambos)
{
  "$setKinds": ["Invoice"],
  "subtotal": 100,
  "tax": { "$js": "subtotal * 0.21" },
  "total": { "$js": "subtotal + tax" }
}
BQL · actualizar · campos almacenados + autoincremento
{
  "$id": "line1",
  "total": { "$js": "price * qty" },
  "count": { "$js": "count + 1" }
}
BQL · transformaciones de cadenas y slugs
{
  "$setKinds": ["Post"],
  "title": "Hello World",
  "slug": { "$js": "title.toLowerCase().replaceAll(' ', '-')" }
}
BQL · expresión typescript · tipos borrados antes de ejecutar
{
  "$setKinds": ["Invoice"],
  "subtotal": 100,
  "total": { "$ts": "const rate: number = 0.21; subtotal * (1 + rate)" }
}
BQL · flujo completo · 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 también funciona en las consultas como campos virtuales: valores calculados que solo existen en la respuesta, con el prefijo %.

BQL · campos virtuales en consultas
{
  "$kinds": "Order",
  "$fields": [
    "name", "price", "quantity",
    { "%line_total": { "$js": "price * quantity" } },
    { "%tax_label": { "$js": "'Tax: ' + (price * 0.21).toFixed(2)" } }
  ]
}
Literales tipados

Fuerza que un valor JSON tenga un tipo concreto al escribir. La forma canónica es la clave de objeto con el prefijo #: el espacio de nombres $ está reservado para los operadores y selectores de BQL.

BQL · forma canónica de objeto con prefijo #
{
  "$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 requiere zona horaria (Z o +02:00). #decimal conserva la base 10 exacta. #duration combina w/d/h/m/s/u/n y admite negativos. #text escapa una cadena que si no se interpretaría como azúcar de DX (por ejemplo ".name" dentro de una expresión nativa). Seis castings (unit / date / datetime / time / decimal / duration) también aceptan el atajo heredado de DX con prefijo en la cadena "<datetime>2024-01-15T10:30:00Z"; el resto solo admiten la forma de objeto.

Valores de intervalo

Un campo INTERVAL almacena un conjunto de rangos. Consúltalo con $has (contención completa de un punto o de un subconjunto) y con $intersects (cualquier solapamiento). $contains es solo subcadena de TEXT.

BQL · escritura y consultas de intervalos
// 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": { "$has": { "#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" }]]
      }
    }
  }
}
Expresiones nativas

$expr es un carril rápido verificado para funciones concretas de rutas calientes, con el nombre @namespace.fn. Dentro de una llamada con @, una cadena que empieza por . es azúcar de DX para referirse a un campo; envuélvela en #text para conservar la cadena literal. $js sigue siendo la superficie amplia para todo lo que aún no está cubierto.

BQL · campo virtual nativo @text.concat
{
  "$kinds": "User",
  "$fields": [
    "name",
    { "%fullName": { "$expr": { "@text.concat": [".name", " ", ".familyName"] } } }
  ]
}

Respuestas

Todas las respuestas siguen una estructura consistente. La forma de data es predecible a partir de tu consulta.

#

Forma de la respuesta

El campo data es un objeto o null cuando el selector es demostrablemente único: un $id escalar, un $iid escalar o un filtro de igualdad sobre un campo de datos unique (por ejemplo $filter: { "email": "x" } con email marcado como unique: true). La igualdad con null sobre un campo unique sigue siendo MANY. Las consultas guiadas solo por $kinds, por filtros no unique, por $id.$in, por un $or en la raíz o por $in devuelven un array, aunque el filtro coincida con una sola fila. $limit: 1 no cambia la forma de la respuesta.

BQL · ONE · un solo $id
{
  "data": {
    "$id": "abc123",
    "$kinds": ["User"],
    "name": "Alice",
    "email": "alice@test.com"
  },
  "outcome": "completed"
}
BQL · MANY · por $kinds
{
  "data": [
    { "$id": "abc123", "$kinds": ["User"], "name": "Alice" },
    { "$id": "def456", "$kinds": ["User"], "name": "Bob" }
  ],
  "outcome": "completed",
  "meta": { "count": 2, "timing_ms": 1.23 }
}

Añade $explain a una consulta o mutación para incluir el plan en la respuesta. "basic" devuelve la lista de pasos y el número de filas; "full" añade los tiempos por paso. Útil para afinar.

BQL · $explain · plan y tiempos en la respuesta
{ "$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": { ... } }
}
#

Metadatos

Toda unidad incluye $id y $kinds por defecto. $meta solo es válido dentro de $fields. $meta no es una clave raíz de consulta; inclúyelo dentro de $fields para obtener todos los campos meta siempre activos. Los metadatos contextuales ($score, $history) hay que pedirlos de forma explícita. Los campos de marca de tiempo usan las claves públicas canónicas $createdAt y $updatedAt; ambos se serializan como cadenas ISO.

$id
ID público (idField personalizado o ULID interno)
$iid
ULID interno (22 caracteres en base62)
$kinds
Todos los nombres de clase (siempre un array)
$version
Contador de mutaciones
$createdAt
Marca de tiempo ISO-8601 de creación
$updatedAt
Marca de tiempo ISO-8601 de la última mutación
$meta
Abreviatura: se expande a todo lo anterior
$score
Puntuación de relevancia BM25, solo con $search (hay que pedirla)
$history
Flujo de eventos por unidad (en línea con $fields; "$history" = IDs, { $expand: "$history" } = entradas)
#

Resultados y avisos

outcome es el estado de la operación. Los resultados completed, committed y partial usan 200; el trabajo aceptado usa 202; el trabajo rechazado usa normalmente 422 (o 429 por contrapresión).

  • issues es una única lista plana de diagnósticos ordenada por severidad
  • severity es error, warning o info
  • phase es request, execution, commit o post_commit

Una consulta cuyo $filter no ha coincidido con ninguna unidad (y que no era una búsqueda por identidad ni por $offset), o que ha usado un $in: [] vacío, sigue teniendo éxito con data: [], pero lleva un aviso que explica el resultado vacío. Cada aviso tiene un código estable; el contexto propio de cada aviso va en details.

BQL · aviso de resultado vacío (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 · respuesta rechazada (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 }
}

Ramifica según issue.code con el catálogo tipado que exporta @blitzgraph/client-core (SERVER_ISSUE_CODES, con particiones separadas de errores y de diagnósticos): el catálogo se genera desde el servidor, así que un código desconocido significa que el cliente está desactualizado.

UNKNOWN_KIND
Se ha referenciado una clase que no está definida (incluye una sugerencia por errata)
BATCH_TOO_LARGE
El lote de mutación ha superado maxBatchSize (trocea con data_import o la importación de namespace)
AMBIGUOUS_FIELD_ACROSS_KINDS
Un campo escrito resuelve a definiciones en conflicto entre las clases de la unidad
UNKNOWN_OPTS_KEY
Clave de opción desconocida; incluye data: { unknown, allowed }
PERMISSION_DENIED
El token autenticado no tiene permiso para esa ruta
SIGN_IN_REQUIRED
La admisión al portal necesita una sesión de usuario de aplicación
ACCESS_FORBIDDEN
La regla de acceso del portal ha denegado al sujeto vivo
SECRETS_KEK_UNAVAILABLE
Las operaciones con secretos requieren BLITZSTORE_SECRET_ENV_VAULT_KEYRING
NOT_IMPLEMENTED
Opción reservada que todavía no se aplica: parallel, limits.maxMemoryBytes, limits.maxRegexComplexity
SUBSPACE_LIMIT_EXCEEDED
Crear el subspace superaría el límite de subspaces del namespace

Por defecto, las mutaciones acumulan todos los errores para que veas todos los problemas de una vez. Pon failFast: true en las opciones de mutación para parar en el primer error.