npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@loradb/lora-graphql

v0.24.1

Published

Schema-first GraphQL for LoraDB: one annotated SDL becomes an executable, index-aware GraphQL API that compiles every operation to a single Cypher statement.

Readme

@loradb/lora-graphql

Schema-first GraphQL for LoraDB. One annotated SDL describes the graph and the public API. The library turns it into an executable graphql-js schema whose operations compile to parameterised Cypher statements, and it reasons about those statements: it derives the indexes they need, checks with explain() that they use them, bounds their cost before they run, compiles authorization into them, and reports exactly what every mutation wrote.

import { createDatabase } from "@loradb/lora-node";
import { LoraGraphQL, loraDriver } from "@loradb/lora-graphql";
import { createYoga } from "graphql-yoga";

const typeDefs = /* GraphQL */ `
  type Festival @node @mutation @query(aggregate: true) {
    key: ID! @key(generate: true) @relayId
    name: String! @filterable(byValue: [EQ, CONTAINS]) @sortable
    capacity: Int @filterable(byValue: [GTE, LT]) @sortable
    location: Point @filterable(byValue: [WITHIN_BBOX, DISTANCE])
    createdAt: DateTime @timestamp(operations: [CREATE])
    genre: Genre @relationship(type: "IN_GENRE", direction: OUT) @filterable
    followers: [User!]!
      @relationship(type: "FOLLOWS", direction: IN, properties: "Follows")
      @filterable
    followerCount: Int!
      @cypher(statement: "RETURN size([(this)<-[:FOLLOWS]-(:User) | 1]) AS n")
  }
  type Genre @node {
    key: String! @key
    name: String! @filterable
  }
  type User @node @mutation {
    key: String! @key
    name: String @sortable
  }
  type Follows @relationshipProperties {
    since: Int @default(value: 2026)
  }
`;

const db = await createDatabase();
const lora = new LoraGraphQL({ typeDefs, driver: loraDriver(db) });
await lora.assertSchema({ create: true }); // the constraints and indexes the API needs
const yoga = createYoga({
  schema: lora.getSchema(),
  plugins: [lora.envelopPlugin()], // document guards (and atomic mutations)
  context: ({ request }) => ({
    jwt: verifiedClaims(request),
    signal: request.signal,
  }),
});

Contents

What it generates

For each @node type (reads are on by default; @query(read: false) turns them off):

| Field | Does | | -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | | festivals(where, sort, limit) | A bounded list | | festivalsConnection(where, sort, first, after, last, before) | Relay connection with keyset cursors in both directions, totalCount and aggregate; never SKIP | | festival(key:) | Lookup by @key | | festivalsAggregate(where) | count, and min / max (avg / sum for numbers) of sortable fields, with @query(aggregate: true) | | searchFestivals(query, where, limit) | Full-text search, with @fulltext | | similarFestivals(vector or to, where, limit) | Vector similarity, with a @vector field | | node(id:) | Any @relayId type by global id | | nodes(ids:) | Many global ids at once, in order; null where unknown or hidden (at most maxLimit) | | events(where, sort, limit) | An interface's or union's members together | | createFestivals, upsertFestivals | With @mutation(CREATE) (upsert also needs UPDATE) | | updateFestival, updateFestivals(where, limit) | With @mutation(UPDATE): by key, or bulk by where | | deleteFestival, deleteFestivals(where, limit) | With @mutation(DELETE): by key, or bulk by where | | festivalChanged(key, operations, where) | A subscription, with @subscription |

Relationship fields take where, sort and limit; list relationships also get …Connection, whose edges carry the relationship properties and filter on them (where: { node, edge }).

The surface is restrictive: a field is filterable only with @filterable, and only by the operators listed; sortable only with @sortable; printPublicSchema() prints exactly what clients see, with no directives.

Directives

A directive applies where the tables below say, and nowhere else: one in a position the model would not apply (an @authorization on an interface field, a @selectable on a field of an object type without @node, a @limit on a scalar field) is a model error naming the directive and the position, never silently ignored. DIRECTIVE_POSITIONS in src/model/positions.ts is the full table.

A directive on an extension (extend type Secret @authorization(...), extend interface, extend union, extend scalar, extend schema) applies exactly as on the definition, so a type's rules may live in another file. Repeatable directives (@uniqueTogether, @authorizationRule) add up across the definition and its extensions; any other directive written on both is an error. extend type X @node makes a plain type X a node type; extending a type that is never defined is an error.

Model:

| Directive | On | Meaning | | ---------------------------------------------------------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | @node(labels:, plural:) | type | A node label set; default: the type name | | @key(generate:) | field | Required, unique and immutable. The sort tie-breaker, cursor anchor and mutation address. generate: true fills a UUID on create | | @unique | field | Uniqueness constraint | | @uniqueTogether(fields:, where:) | type | No two nodes (matching where) share these fields: scalars, single relationships, at most one list relationship as a set. Repeatable; see Mutations | | @index(kind: RANGE \| TEXT \| POINT) | field | An explicit index, usually inferred | | @storedAs(type:) | custom scalar | How a custom scalar is stored (STRING, INT, FLOAT, BOOLEAN, DATETIME, DATE); its SDL description is what clients see, whatever implementation scalars passes | | @relationship(type:, direction:, properties:, queryDirection:, onDelete:, nestedOperations:, aggregate:) | field | An edge to a @node type, interface or union. queryDirection: UNDIRECTED reads both ways; onDelete: DETACH \| CASCADE \| RESTRICT; nestedOperations lists the nested writes inputs offer; aggregate: false drops its aggregates | | @declareRelationship | interface field | Every implementation declares this relationship (type and direction may differ); select it on the interface | | @relationshipProperties | type | Properties on a relationship type | | @alias(property:) | field | API name differs from the stored property | | @private | field | Stored, never exposed | | @readonly | field | Exposed, never client-settable; on a relationship, absent from create and update inputs | | @settable(onCreate:, onUpdate:) | field | Which mutations may set it, e.g. set once on create; on a relationship, whether the inputs offer it (an upsert of an existing node keeps it); on a relationship property, onUpdate: false also refuses a re-connect that would change it | | @selectable(onRead:, onAggregate:) | field | onRead: false makes a field write-only; on a relationship property, it leaves the edge type too (onAggregate: false, the edge aggregates); with every property hidden, the edge has no properties | | @default(value:) | field | Stored on create when the input omits it; on a relationship property, when the relationship is created | | @timestamp(operations: [CREATE, UPDATE]) | field | Set to the current time; client-settable only with an explicit @settable and a field rule (see Mutations). On a relationship property: CREATE when the relationship is created, UPDATE on edge updates and re-connects that set properties | | @populatedBy(callback:, operations:) | field | Computed by a named callback on write | | @cardinality(max:) | list relationship | Declared fan-out, for cost estimates | | @cypher(statement:, columnName:) | field | A field backed by a Cypher statement. Returns scalars, @node types, interfaces or unions over them, or object types without @node (read from a map) | | @fulltext(indexes: [{ name, fields, analyzer, queryName }]) | type | FULLTEXT indexes, each with a search root field | | @vector(dimensions:, similarity:, queryName:) | [Float!] field | A VECTOR index and a similarity root field | | @plural(value:) | interface, union | The root field's name |

API:

| Directive | On | Meaning | | ----------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | | @query(read:, aggregate:) | type, interface, union | Generated reads | | @mutation(operations: [CREATE, UPDATE, DELETE]) | type | Generated mutations; none without it | | @subscription(operations: [CREATE, UPDATE, DELETE]) | type | Generated subscriptions; none without it | | @filterable(byValue: [...]) | field | Filter operators. Bare: EQ and IN (lists: INCLUDES). On a relationship: enables relationship filters | | @sortable | field | Sort and paginate by this field (on a relationship property: sort: [{ edge: { ... } }]) | | @groupBy | field | A grouping key of <plural>Grouped(by:) (needs @query(aggregate: true)) | | @limit(default:, max:) | type, interface, union, list relationship | Page size bounds | | @size(max:) | list argument of a @cypher field | The most items it takes (default maxListArgument) | | @range(min:, max:) | Int / Float argument of a @cypher field | Bounds of its value (each item of a list); outside is BAD_USER_INPUT | | @relayId | @key field | Adds a global id and the Node interface | | @authentication(operations:, jwt:) | type, field | Needs an authenticated request, whose claims satisfy jwt | | @authorization(filter:, validate:) | type (filter and validate), field (validate) | Row-level rules, compiled into statements | | @jwt, @jwtClaim(path:) | type, field | The claims shape; rules may only use declared claims | | @viewer(type:, field:) | @jwt claim | The claim naming the caller's node (by a @key or @unique field): enables isViewer and viewer in rules |

directiveTypeDefs (or lora-graphql directives) prints these as SDL for editors and codegen.

Filter operators: EQ, IN, LT, LTE, GT, GTE, CONTAINS, STARTS_WITH, ENDS_WITH, CASE_INSENSITIVE (strings), IS_NULL (nullable fields), INCLUDES (lists), and on points WITHIN_BBOX and DISTANCE. Each is checked against the field's type.

Types: String, ID, Int, Float, Boolean, enums, BigInt (a decimal string), Date, Time, LocalTime, DateTime, LocalDateTime, Duration (ISO-8601 strings), Point and CartesianPoint (objects; set with PointInput / CartesianPointInput), and non-null lists of these.

Queries

{
  festivalsConnection(
    first: 10
    after: $cursor
    where: {
      name: { caseInsensitive: { contains: "land" } }
      followers: { some: { key: { eq: "u1" } } }
      followersConnection: { some: { edge: { since: { gte: 2020 } } } }
      OR: [{ capacity: { gte: 5000 } }, { genre: { name: { eq: "Techno" } } }]
    }
    sort: [{ capacity: DESC }]
  ) {
    totalCount
    aggregate {
      count
      node {
        capacity {
          max
          avg
        }
      }
    }
    edges {
      cursor
      node {
        key
        name
        followerCount
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
  • Filters nest, with AND / OR / NOT. List relationships take some, all, none, single, count and aggregate (node / edge field min, max, avg, sum); single relationships take the target's filter directly; <field>Connection quantifies over node and relationship properties together.
  • Absent and null filters are left out of the statement, so a filter bound to an unset variable costs nothing. eq: null does not mean IS NULL: use isNull: true. An OR branch left empty that way is left out of the OR, and an OR or NOT with nothing left is left out entirely, so an unset variable never widens a filter to every row; a literal OR: [] matches nothing. A relationship quantifier whose filter is empty is left out too: ask count: { gt: 0 } for "has any". A single relationship takes <field>Exists: Boolean for "is set" (venueExists: false: festivals without a venue); as in every relationship filter, a related node the reader may not see counts as none.
  • count and single count related nodes once each, however many relationships lead to them. all holds on an empty set, and a missing property fails it.
  • CASE_INSENSITIVE compares lowercased values and cannot use an index: prefer @fulltext for search over large labels.
  • Connections page forward with first / after and backward with last / before; one cursor works in both directions. aggregate covers every match, not only the page; selecting only totalCount or aggregate reads no page.
  • sort takes one field per item. Lists sort by the requested fields only; connections always end on a unique, non-null field (the @key, or an earlier required @unique field) so cursors are stable. A cursor is tagged with its sort, and replaying it under another sort is an INVALID_CURSOR error. With cursorSecret it is also signed (HMAC-SHA-256), and any cursor the server did not issue is rejected. Nested lists without a sort come in @key order.
  • Nulls sort last ascending and first descending, as in Cypher, and keyset pages handle them.
  • Every list is bounded: limit / first default to @limit(default:) (global 25) and asking for more than max (global 100) is a LIMIT_EXCEEDED error, not a silent clamp.

More query surface

  • Edge sort. A relationship connection sorts by @sortable relationship properties: followsConnection(sort: [{ edge: { since: DESC } }, { name: ASC }]). Cursors carry the edge value; relationship properties have no index, so this sorts per parent, bounded by the page.
  • Grouped aggregates. sessionsGrouped(by: [kind, room], where:, limit:) returns [{ by { kind room } aggregate { count minutes { sum } } }], ordered by the group values, at most limit groups.
  • Richer aggregates. Strings add shortest / longest, and aggregate filters take shortestLength, longestLength and averageLength. Durations aggregate min, max, sum and avg. Relationship connections count count { nodes edges }: they differ when several relationships lead to the same node.

Interfaces and unions

interface Event @limit(default: 10) {
  key: String!
  title: String! @filterable(byValue: [EQ, CONTAINS]) @sortable
  starts: Int @sortable
}
type Concert implements Event @node @mutation {
  key: String! @key
  title: String!
  starts: Int
  band: String
}
type Exhibition implements Event @node @mutation {
  key: String! @key
  title: String!
  starts: Int
  artist: String
}
union Headline = Concert | Exhibition
type Venue @node @mutation {
  key: String! @key
  events: [Event!]! @relationship(type: "HOSTS", direction: OUT) @filterable
  headline: Headline @relationship(type: "HEADLINES", direction: OUT)
}
{
  events(
    where: { title: { contains: "Rock" }, typename: [Concert] }
    sort: [{ starts: ASC }]
  ) {
    __typename
    key
    title
    ... on Concert {
      band
    }
  }
  headlines(where: { Exhibition: { title: { eq: "Modern art" } } }) {
    __typename
  }
  venue(key: "v1") {
    events(limit: 5) {
      key
    }
  }
}

Interfaces and unions range over @node types. An interface's @filterable and @sortable fields apply to every implementation, and so does index inference: each implementation's label gets the index. A root list or relationship field over an interface runs one sorted, limited subquery per implementation, each able to use its own index, and merges them by the requested sort, then type name, then key. An interface where takes the interface's fields plus typename; a union where takes one filter per member, and once any member is named, members not named are left out. Mutations connect, create and disconnect per member: events: { connect: { Concert: [{ key: "c1" }] } }. A single relationship to a union holds one node across all members.

Interface relationships

An interface field under @declareRelationship is a relationship every implementation declares (with the same target and shape; the type and direction may differ), so events { venue { name } } works at interface level.

Mutations

Mutations exist only for types with @mutation, and address nodes by @key, so every write-set is exact.

mutation {
  createFestivals(
    input: [
      {
        name: "Sunland"
        genre: { create: { node: { key: "techno", name: "Techno" } } }
        followers: { connect: [{ key: "u1", edge: { since: 2020 } }] }
      }
    ]
  ) {
    festivals {
      key
      name
      genre {
        name
      }
    }
    info {
      nodesCreated
      relationshipsCreated
    }
  }
  updateFestival(
    key: "f1"
    update: {
      capacity: null # removes the property
      genre: { connect: { key: "house" } } # replaces the single relationship
      followers: {
        disconnect: ["u2"]
        update: [{ key: "u1", edge: { since: 2021 } }] # in place
      }
    }
    adjust: { visits: { add: 1 }, tags: { push: ["summer"] } }
  ) {
    festival {
      key
    }
  }
  updateFestivals(
    where: { capacity: { lt: 100 } }
    adjust: { capacity: { multiply: 2 } }
    limit: 50
  ) {
    info {
      nodesUpdated
    }
  }
  deleteFestival(key: "f2") {
    nodesDeleted
    relationshipsDeleted
  }
}
  • Updates set fields (null removes one) and relationships (connect, create, disconnect, and update of connected nodes and relationship properties in place). Connecting an already-connected pair keeps one relationship and sets the properties the input gives; the others, @defaults included, keep their values. Defaults apply when a relationship is created. Re-connecting a single relationship to its current target keeps that relationship.

  • adjust applies math (add, subtract, multiply, divide) and list (push, pop, remove) operators to the stored value atomically; a missing number counts as 0 and a missing list as empty.

  • Bulk updateFestivals / deleteFestivals resolve the keys where matches under the authorization filter, then run the keyed path, so their write-sets are exact too. More matches than limit (default maxBatch) is an error that writes nothing, and an empty where is refused.

  • upsertFestivals creates the inputs whose key is new and updates the rest; fields required on create are required only for new keys. A key the caller may not see is never updated, and never reported as taken: it gets the answer a free key would (see below).

  • Creating under a hidden key (createFestivals, upsertFestivals, a nested create) answers as if the key were free: whatever error the free key would get, and where it would be created, FORBIDDEN ("not allowed to create"), never a CONSTRAINT_VIOLATION that would confirm the key exists. A key held by a node the caller can read is still reported as taken. See docs/design/graphql-threat-model.md.

  • Deletes follow onDelete: DETACH (default) removes the relationships, CASCADE deletes what the field reaches (checking the caller may delete each node), RESTRICT refuses while related nodes remain.

  • @populatedBy(callback: "slug") computes a field with callbacks: { slug: ({ input, key, context, operation }) => … }.

  • Supplying a computed field. @timestamp and @populatedBy fields are not in the inputs, unless @settable(onCreate: true) (or onUpdate: true) says so explicitly and a field-level @authorization(validate:) rule for that operation decides who may supply the value. A supplied value is stored as given (a seed backfilling history, an import keeping its dates); an omitted one is computed as before. Without such a rule the combination is a model error, so a computed field never becomes client-settable by accident: the schema's bypass and @authentication are not rules here, and a bare @settable (its defaults) changes nothing. Relationship properties cannot opt in.

    createdAt: DateTime! @timestamp(operations: [CREATE]) @settable(onCreate: true)
      @authorization(validate: [{ operations: [CREATE], where: { jwt: { roles: { includes: "admin" } } } }])

Each mutation runs in one interactive transaction and checks, before it commits:

  • every connect target exists and is visible to the caller (NOT_FOUND);
  • a single relationship stays single and a required one stays set, even when written from the other side or when its target is deleted (CONSTRAINT_VIOLATION);
  • every @uniqueTogether holds (CONSTRAINT_VIOLATION, below);
  • @authorization validate rules, type and field level (FORBIDDEN).

@uniqueTogether states a uniqueness the engine's per-property constraints cannot: over relationship ends.

type ConnectionRequest @node @uniqueTogether(fields: ["from", "to"]) {
  key: ID! @key(generate: true)
  from: Person! @relationship(type: "SENT", direction: IN)
  to: Person! @relationship(type: "TO", direction: OUT)
}
type Conversation
  @node
  @uniqueTogether(fields: ["participants"], where: { kind: { eq: DIRECT } }) {
  key: ID! @key
  kind: ConversationKind!
  participants: [Person!]! @relationship(type: "IN", direction: IN)
}

fields names scalar fields (compared by value), single relationships (by the target's @key) and at most one list relationship (by the set of target keys, in any order); where limits the nodes compared. A combination with a null scalar, no target or an empty set is exempt, as a null is in a unique index. Every generated mutation checks it after its writes — creates, updates, upserts, nested creates, and connects, disconnects and deletes from either side — for every caller, the bypass included: it is a data invariant, not a rule. Each check seeks the written nodes and compares only with nodes sharing their first relationship end (or, with scalars only, their first scalar's value).

A @cypher mutation's write-set is unknown, so after its statement it checks every @uniqueTogether type the statement may write: one whose label, constrained relationship type or constrained scalar property (.prop) the statement text names. That check compares every node of the type (a scan, in the same transaction), and the model warns about each such mutation (check() reports it under warnings); prefer a generated mutation for a constrained type. Cypher of your own (tx.execute(), other clients) is not checked.

Any failure rolls the whole mutation back. Atomicity is per root field: in an operation with several root fields, each runs in its own transaction, so a later failure leaves the earlier ones committed. Pass mutationTransaction: "operation" to run every root field of a mutation in one transaction through execute() (persisted operations included): it commits only when the operation reports no error, and otherwise rolls back and returns data: null. GraphQL Yoga and other Envelop servers on getSchema() get the same from lora.envelopPlugin(). With another server calling graphql-js directly, put a lora.begin() transaction in the context (see Transactions); without one, each root field commits on its own, and the first such mutation logs a warning. Engine constraint errors come back as CONSTRAINT_VIOLATION naming the type and field. A mutation writes at most maxBatch nodes (default 1000): created and updated nodes count together (every upsert input, nested creates, each nested update entry), and a delete reaches at most maxBatch nodes through onDelete: CASCADE. Relationships written (connects, each key of a disconnect list, nested update entries) count up to ten times maxBatch. Going over is LIMIT_EXCEEDED, before the writes that would exceed it. @key is not updatable. info reports nodesCreated, nodesUpdated, nodesDeleted, relationshipsCreated and relationshipsDeleted.

Nested delete and trimmed inputs

update: { stages: { delete: { where: { size: { gt: 2 } }, limit: 10 } } } deletes connected nodes (a single relationship takes delete: true), bounded like bulk deletes and following onDelete. limit defaults to maxBatch; more matches is LIMIT_EXCEEDED. @relationship(nestedOperations: [CONNECT]) keeps only the listed nested writes in the inputs, and aggregate: false removes the relationship's aggregates and aggregate filter. A nested update: [{ key, edge, node }] (UPDATE) changes the connected node and the relationship's properties; UPDATE_EDGE offers update: [{ key, edge }] alone, so an input can keep "set my RSVP" without advertising "edit the festival" (it needs relationship properties).

An input left with no field is left out, with what would take it: a relationship without settable properties has no edge input, and a type with nothing settable on update (no settable field, no relationship with a nested write) has no update mutations; the model warns when @mutation(operations: [UPDATE]) asked for them. A generated schema graphql-js would reject is a ModelError from getSchema(), never an error on every request.

Search

type Event @node @fulltext(indexes: [{ fields: ["title", "summary"] }]) {
  key: String! @key
  title: String!
  summary: String
  embedding: [Float!] @vector(dimensions: 384, similarity: COSINE)
}
{
  searchEvents(query: "techno sun*", where: { city: { eq: "Ams" } }) {
    score
    node {
      key
      title
    }
  }
  similarEvents(to: "e1", limit: 5) {
    score
    node {
      key
    }
  }
}

Full-text queries AND their terms, fold case and accents, and treat a trailing * as a prefix. A [String!] field in @fulltext indexes each of its strings. Search connections (searchEventsConnection) page with cursors and take totalCount: every match after where and the read rules (a vector search counts within its candidate window); a selection of only totalCount reads no page. Vector search takes a query vector or the key of a node whose embedding to start from (to, which is left out of the results). The index returns its top candidates before where applies, so the library asks it for four times the page when a filter is present. @vector fields are stored as VECTOR values, which the index requires, and read back as [Float!]. Both indexes are part of S1 and created by assertSchema({ create: true }); read rules apply to every result.

An index matches and ranks by stored values, whatever the read rules say, so search never reaches a value the reader may not read:

  • A @fulltext index over a field with a mask, a field-level READ validate rule or @authentication for READ is a model error naming the field: searching it would tell which rows contain a hidden word. Leave the field out of the index (index a public copy if it must be found).
  • A vector search, with vector or to, needs the vector field's @authentication, and ranks only nodes passing its READ validate rules (the anchor of to too); a node whose vector the reader may not read is not a candidate. A mask on a @vector field is a model error.

Every search also has a connection: searchDocsConnection(query:, where:, first:, after:) pages by keyset on (score, key). A vector index returns its top candidates before any filter, so vector connections page within 4 × @limit(max:) candidates.

@cypher fields

type Festival @node {
  key: ID! @key
  similar(limit: Int = 3): [Festival!]!
    @cypher(
      statement: """
      MATCH (this)-[:IN_GENRE]->(:Genre)<-[:IN_GENRE]-(other:Festival)
      WHERE other.key <> this.key
      RETURN other ORDER BY other.name LIMIT $limit
      """
      columnName: "other"
    )
}

type Query {
  festivalCount: Int!
    @cypher(statement: "MATCH (f:Festival) RETURN count(f) AS n")
}

type Mutation {
  renameGenre(key: String!, name: String!): Genre
    @cypher(
      statement: "MATCH (g:Genre) WHERE g.key = $key SET g.name = $name RETURN g"
    )
}

this is the parent node; arguments are $parameters; $jwt holds the request's claims; $viewer is the caller (below). columnName is inferred when the statement's last top-level RETURN has one item, RETURN x or RETURN … AS x (commas inside calls, lists, maps and CALL { } do not count); with several items, set it. Fields returning @node types are projected with the selection like any other node, and read filters apply to them.

Statements are checked, not trusted:

  • at startup: every $parameter must be an argument, $jwt or $viewer, Query and object fields may not contain write clauses, and unused arguments, OPTIONAL MATCH and statements that never use this are warnings (lora.model.warnings);
  • in check(): every statement is planned with explain(), so a syntax error, an unknown function or a missing column fails CI with the engine's message, not the first request.

The caller: $viewer

With a @viewer claim, $viewer is the caller's node's @key, in field and mutation statements alike, so a statement names the viewer, not the claim:

type Mutation {
  createPost(key: String!, caption: String!): Post
    @authentication
    @cypher(
      statement: """
      MATCH (a:Person) WHERE a.key = $viewer
      CREATE (a)-[:POSTED]->(p:Post {key: $key, caption: $caption})
      RETURN p
      """
    )
}

When @viewer maps to the key, $viewer is the claim itself. When it maps to another field (an opaque subject), it is read in the statement with one seek by the claim (head([(v:Person {subject: $claim}) | v.key])), so the statement above keeps working, and keeps seeking, when @viewer moves from Person.key to Person.subject. $viewer is null signed out, for a claim that is not a string or number, and for a token naming no node. A statement using it without a @viewer claim is a model error, and viewer is reserved as an argument name, like jwt.

Guarding root fields

@authentication and @authorization(validate:) guard a Query or Mutation @cypher field before its statement runs. With no node, a rule tests claims (jwt) and the caller's own node (viewer); node and filter rules are model errors, and operations / when do not matter: each rule guards the call.

type Mutation {
  verify: Person
    @authentication
    @authorization(
      validate: [{ where: { viewer: { verified: { eq: true } } } }]
    )
    @cypher(
      statement: "MATCH (p:Person) WHERE p.key = $viewer SET p.verified = true RETURN p"
    )
}

As anywhere, any passing rule grants, and the schema's bypass skips them. Claim tests are decided in JavaScript: a refusal (FORBIDDEN, or UNAUTHENTICATED without a token) runs no statement. A viewer test is one seek, in the mutation's transaction, before the statement. Every root @cypher field is in the access matrix, guarded or not (Mutation fields under the operation EXECUTE).

List and number arguments

The statement sees its arguments as sent, so list arguments are capped: at most @size(max:) items, or maxListArgument (default 1000) without it. More is BAD_USER_INPUT before any statement runs; each level of a nested list counts.

createPost(key: String!, hashtags: [String!] = [] @size(max: 30)): Post

Int and Float arguments take bounds the same way: @range(min:, max:) (either or both, inclusive) checks the value, or each item of a list, before the statement runs, and a default outside the bounds is a model error. Null is not checked; that is the type's business.

nearby(lat: Float! @range(min: -90, max: 90), km: Int = 10 @range(min: 1, max: 500)): [Festival!]!

Filters, sorts and richer results

A scalar @cypher field of a @node type may take @filterable and @sortable: the statement then runs per node in a CALL before the filter, in root fields only (through a relationship it is refused). No index applies, and the model warns so check reports it.

A @cypher field may return an interface or union over @node types (each node is projected as the member its label says) or an object type without @node, whose fields are read from the returned map: RETURN { events: count(e), titles: collect(e.title) } AS s.

Authorization

The library does not verify tokens. Verify them in your server and put the claims in the context as jwt (or pass jwt: (context) => claims).

type Claims @jwt {
  sub: String!
  roles: [String!] @jwtClaim(path: "app_metadata.roles")
}

extend schema @authorizationDefaults(requireAuthentication: false)

type Post
  @node
  @mutation
  @authentication(operations: [CREATE, UPDATE, DELETE])
  @authorization(
    filter: [
      { where: { node: { published: { eq: true } } } }
      { where: { node: { author: { key: { eq: "$jwt.sub" } } } } }
      {
        where: {
          AND: [
            { jwt: { sub: { exists: true } } }
            { node: { tenant: { eq: "$context.tenant" } } }
          ]
        }
      }
      { where: { jwt: { roles: { includes: "admin" } } } }
    ]
    validate: [
      {
        operations: [CREATE, UPDATE]
        when: [AFTER]
        where: {
          OR: [
            { node: { author: { key: { eq: "$jwt.sub" } } } }
            { jwt: { roles: { includes: "admin" } } }
          ]
        }
      }
    ]
  ) {
  key: ID! @key(generate: true)
  title: String!
  tenant: String!
  published: Boolean! @default(value: false)
  notes: String @authentication(operations: [READ])
  royalties: Int
    @authorization(
      validate: [
        {
          operations: [READ]
          where: { node: { author: { key: { eq: "$jwt.sub" } } } }
        }
      ]
    )
  author: User! @relationship(type: "WROTE", direction: IN)
}
  • A rule takes operations, when (validate rules) and where; any other field is a model error, so a misspelt operations cannot fall back to the default unnoticed.

  • A rule is { node, jwt, AND, OR, NOT }. node is a filter over the type (relationship properties included, through <field>Connection: { some: { node, edge } }), where "$jwt.path" strings become the caller's claims and "$context.path" strings values from the GraphQL context. jwt tests claims (eq, in, includes, contains, startsWith, endsWith, lt, lte, gt, gte, exists).

  • Anonymous callers. Without a token every rule denies, unless the schema says @authorizationDefaults(requireAuthentication: false). Then a rule with a branch that reads no claims decides that branch for anonymous callers too (the published posts above). A rule that needs claims still denies them, and how depends on the kind of rule: a validate rule asks for a token (UNAUTHENTICATED, not FORBIDDEN), while a READ filter admits no row, so a signed-out caller reads an empty list and the access matrix calls it denied. It is one setting for the schema; a rule has no such field, and it reaches every rule: a CREATE, UPDATE, DELETE or CONNECT rule that reads no claims decides for signed-out callers as well. check names each such write rule that no @authentication covers. To keep a claim-free rule or branch for signed-in callers, test a claim beside it ({ jwt: { sub: { exists: true } } }, as the tenant rule above does), or put @authentication on the type or the relationship field.

  • The caller's own node. Mark the claim that identifies the caller with @viewer(type: "Person", field: "subject") (the field is @key or @unique), so the key can stay a slug while the subject is an identity provider's opaque id. Rules then say { node: { isViewer: true } } on the viewer type, or { node: { author: { isViewer: true } } } through a relationship; this expands to { subject: { eq: "$jwt.sub" } } at startup and compiles to exactly the same statement. With @viewer declared, writing that expansion out by hand is a model error: the mapping lives in one place. viewer: { verified: { eq: true } } tests the caller's own node: one seek by the claim. Both are unknown without the claim, so NOT { isViewer: true } never grants a signed-out caller. isViewer takes true only; use NOT for the opposite. It works through relationships and union members (author: { Person: { isViewer: true } }); in a filter over an interface it is a model error, since there is no single type to expand against. In rule strings, "${viewer.key}" (any scalar field of the viewer type) is the caller's own value: the claim itself for the field @viewer maps to, otherwise read with one seek by the claim. Use it for keys built from the caller's key while the claim is an opaque subject: key: { endsWith: ":${viewer.key}" }, key: { eq: "${viewer.key}" }. It stands for one value, not inside a list.

  • The rule's own node. "${node.path}" in a node part reads the node the rule is about, so a rule can relate two of its paths: "the request's recipient is in the conversation it gates" is { node: { conversation: { participants: { some: { key: { eq: "${node.to.key}" } } } } } }. The path ends on a scalar field and steps only through single relationships; the value is read in the statement (the stored one, as rules see it) and stands for one value, not inside a list. In a relationship field's rules, ${source.path}, ${target.path} and ${edge.property} read its ends and properties: "the payer is on the expense's trip" is { source: { trip: { members: { some: { key: { eq: "${target.key}" } } } } } } on Expense.paidBy's CONNECT. A named rule that reads ${node.…} can't stand inside another node's filter, where it would read the outer node.

  • A whole string starting with $ must be a placeholder ($jwt.<claim>, $context.<path>): a misspelt one ("$jtw.sub") is a model error, not a literal. Write a literal $… as "\\$…".

  • Inside a longer string, write ${jwt.path} or ${context.path}: key: { startsWith: "${jwt.sub}:" } confines a user to keys that begin with their sub and :. The claim must be a string, number or boolean; otherwise the rule denies. Pick a separator no sub contains: with -, user a could take a-b-…, the key space of user a-b. See docs/design/graphql-threat-model.md.

  • Claim tests run in JavaScript at compile time, so an admin's statement carries no filter at all. Statements stay specialised and index-friendly. A write whose rules the claims alone refuse is refused before any statement runs.

  • A create under CREATE rules answers the same whether a @unique value (or the key) is taken by a node the caller may not create: what a free value gets. A create that would succeed answers CONSTRAINT_VIOLATION.

  • A test that needs a claim or context value the request lacks is unknown: false where it stands, and a NOT over it is false too, so negation can never turn a missing claim into a grant. Node conditions follow Cypher: NOT over a null property is not true.

  • filter rules (any passing rule grants) make other nodes invisible: in lists, lookups, counts, aggregates, search, nested relationships, relationship filters, subscriptions, and as targets of updates, deletes and connects. A node the same mutation creates is not hidden from its own connects, so a filter that depends on the new relationship (a request visible to its sender) does not block a nested create. Filter rules for CREATE_RELATIONSHIP and DELETE_RELATIONSHIP guard both ends of connects and disconnects. Update and delete targets (by key, bulk and nested) must pass the type's READ filter as well as its UPDATE / DELETE filter: a key the caller cannot read answers like a missing one (null, nodesDeleted: 0), never FORBIDDEN from a validate rule.

  • Write errors never name a node the caller cannot read. A delete that would leave such a node without a required relationship fails with a Secret the caller can't read requires a Person (Secret.holder); onDelete: RESTRICT held by such nodes fails with Org "o" cannot be deleted: Org.docs has onDelete: RESTRICT. Replacing a single relationship whose current target the caller cannot read is refused with FORBIDDEN (not allowed to replace F.genre), and leaves it in place: the caller cannot remove a relationship of a node they cannot see.

  • validate rules fail the request with FORBIDDEN: BEFORE an update or delete, AFTER a create or update (rolling it back), and for READ: on any returned node, and on cursors, counts and aggregates that cover one. They do not hide nodes from filters; use filter rules for that.

  • Field-level @authorization(validate:) guards one field: reading it on a row that fails is FORBIDDEN, filtering by it only matches rows that pass, sorting or aggregating by it is refused, and writing it checks the rule. On a row failing the rule (or where it is unknown, a null property) the filter is false, never null, so NOT over it matches every such row whatever the hidden value: a negated filter reveals no more than the filter itself. The same holds for relationship and @cypher fields with READ rules, <field>Exists and <field>Connection. A write is what the input sets: a create that leaves the field out is not checked against it, even when @default or @populatedBy fills it, so a CREATE rule can keep a verified: Boolean! @default(value: false) settable by admins only while anyone creates the node. Field-level @authentication also guards filtering, sorting and aggregating on the field.

  • Reading a guarded field is checked per row, token or not: without the token a rule needs, each row reads the field as UNAUTHENTICATED. The statement has the same shape either way, so compile(), explain(), check() and expectSeeks plan-check such operations without a token; pass context (per operation in check({ operations })) to compile them as a signed-in caller.

  • @authentication(operations:, jwt:) covers READ, CREATE, UPDATE, DELETE, CREATE_RELATIONSHIP, DELETE_RELATIONSHIP and SUBSCRIBE, and may require claims. On a relationship field, CREATE / UPDATE cover setting it in a create or update input, CREATE_RELATIONSHIP a connect or nested create through it, and DELETE_RELATIONSHIP a disconnect or nested delete.

  • Rules are checked against the model at startup: an unknown field, operator or (with @jwt) claim, or a test that is empty or null, is an error, not an open door.

  • Owner-scoped keys. key: String! @key(scope: VIEWER, separator: ":") keeps created keys in the caller's key space: a create (nested creates and upsert-creates included) must use a key that starts with the @viewer claim and the separator, and is longer than that prefix. It is checked before any statement runs, so FORBIDDEN for bob:x reads the same whether bob:x exists or not. A claim containing the separator is refused, so user a cannot write into the key space of user a:b. It needs @viewer; the schema's bypass skips it. When @viewer maps to a non-key field (an opaque subject), the key space is the caller's node's @key, looked up once by the claim before anything is written; a token naming no node creates nothing. Keys shared by two owners (f1:lou) stay hand-written rules, with ${viewer.key}.

  • Masks. A field-level READ rule fails the row; a mask substitutes a value instead:

    status: ConnectionRequestStatus!
      @authorization(
        mask: [
          {
            unless: {
              OR: [
                { node: { status: { in: [PENDING, ACCEPTED] } } }
                { node: { to: { isViewer: true } } }
              ]
            }
            value: PENDING
          }
        ]
      )
    lastSeenAt: DateTime
      @authorization(mask: [{ unless: { node: { isViewer: true } } }])

    A row failing unless reads the field as value (null when left out, which a non-null field refuses; value is type-checked). Filters compare the value the reader sees, so a mask never leaks through a filter, and such a filter cannot use the field's index. Rules see the stored value. Sorting, grouping and aggregating by a masked field are refused unless the claims settle the mask (the brief proposed sorting by the masked value; refusing keeps order from hinting at hidden values). Masks sit on scalar fields of @node types other than the @key.

  • Named rules. Define a rule once and use it as { rule: "name" } wherever a rule part may stand:

    extend schema
      @authorizationRules(
        rules: [{ name: "admin", where: { jwt: { roles: { includes: "admin" } } } }]
      )
    
    type Trip
      @node
      @authorizationRule(
        name: "member"
        where: {
          OR: [
            { node: { members: { some: { isViewer: true } } } }
            { node: { owner: { isViewer: true } } }
          ]
        }
      )
      @authorization(
        filter: [{ where: { OR: [{ rule: "member" }, { rule: "admin" }] } }]
      ) { ... }
    
    type PackingItem
      @node
      @authorization(
        filter: [
          {
            where: {
              OR: [{ node: { trip: { rule: "member" } } }, { rule: "admin" }]
            }
          }
        ]
      ) { ... }

    Schema rules (@authorizationRules) test claims only. A type's rules (@authorizationRule, repeatable) are found first, then the schema's; inside a node filter (trip: { rule: "member" }) the name is a rule of that node's type, and that rule must test node only. Rules are inlined at startup, so they compile to exactly the hand-written statement. An unknown name, a type rule shadowing a schema rule, and a cycle (named with its chain) are model errors. bypass and mutations in @authorizationDefaults may name rules too.

  • Schema-wide defaults.

    extend schema
      @authorizationDefaults(
        bypass: { jwt: { roles: { includes: "admin" } } }
        mutations: { jwt: { roles: { includes: "editor" } } }
      )

    A request passing bypass skips every filter and validate rule, field and relationship rules included; @authentication still applies. bypass tests claims only, so it is decided before the statement is built: an admin's statement carries no rule predicate. A type keeps its rules for everyone with @authorization(bypass: false); @authorization(bypass: true) is the default made explicit, and quiets check()'s note that the bypass skips the type's field rules. mutations is the write rule (CREATE, UPDATE, DELETE) of every @mutation type, per operation: it guards each of those operations that none of the type's own rules covers. A validate rule covers the operations it lists; a filter rule covers UPDATE and DELETE when it lists them (by default it does), never CREATE, since there is no node to filter before it exists. So a type with only @authorization(filter: [...]) keeps the default on CREATE, and one with a validate rule for UPDATE only keeps it on CREATE and DELETE. Where a type's rule covers an operation, it replaces the default for that operation, never merges with it. check() fails on a @mutation type whose writes nothing guards, unless it says @authorization(public: [...]). requireAuthentication (see "Anonymous callers" above) is the third setting here.

Relationship and @cypher fields take field-level @authorization with READ validate rules: a row failing the rule reads the field as FORBIDDEN, and filtering through the field applies the rule too.

Relationship properties take field-level @authentication and @authorization(validate:) for READ, CREATE and UPDATE. They test claims (jwt); a node part is a model error. Setting the property on connect or nested create checks CREATE for a new relationship and UPDATE for one that already exists; update: { edge } checks UPDATE. A request the READ rules refuse reads the property as FORBIDDEN and cannot filter, sort or aggregate by it.

When every relationship field using the properties type declares the same ends (owner and target @node types), READ rules may also test the relationship's source, target and edge, as a relationship rule does, and viewer. They decide per relationship: one edge can carry an rsvp every member reads and a marker only its member reads. A relationship failing them reads that property as FORBIDDEN (the others still read), and nothing may filter, sort or aggregate by it. Writes stay claims-only: such a part in a CREATE or UPDATE rule, or on a type whose fields disagree on the ends, is a model error.

type Membership @relationshipProperties {
  rsvp: String
  lastReadAt: String
    @authorization(
      validate: [{ operations: [READ], where: { target: { isViewer: true } } }]
    )
}

Rules on relationships

A relationship field also takes validate rules for the relationship's own operations, where both ends are known:

type Trip @node @mutation {
  key: String! @key
  owner: Person! @relationship(type: "OWNS", direction: IN)
  members: [Person!]!
    @relationship(type: "MEMBER", direction: IN, properties: "TripInvite")
    @authorization(
      validate: [
        # the owner invites
        {
          operations: [CONNECT]
          where: { source: { owner: { isViewer: true } } }
        }
        # the owner removes anyone; a member removes only themselves
        {
          operations: [DISCONNECT]
          where: {
            OR: [
              { source: { owner: { isViewer: true } } }
              { target: { isViewer: true } }
            ]
          }
        }
        # only the member answers their own invitation, and reads its marker
        {
          operations: [UPDATE_EDGE, READ_EDGE]
          where: { target: { isViewer: true } }
        }
      ]
    )
}
  • source is the node declaring the field, target the related node and edge the relationship's properties; jwt, viewer, AND, OR and NOT work as in any rule. A node part is a model error here, and so is a relationship operation on a type or scalar field, or in the same rule as READ.
  • CONNECT covers a new relationship (connect, nested create); DISCONNECT a disconnect, including replacing a single relationship; UPDATE_EDGE update: [{ edge }] and a re-connect that sets properties; READ_EDGE reading the properties. CONNECT and UPDATE_EDGE are checked on the relationship after the write and DISCONNECT before it, in the mutation's transaction: a failure is FORBIDDEN and rolls the mutation back.
  • The rules hold whichever side the write comes from: a connect through Person.trips (the same relationship type, the other direction) answers to Trip.members' rules, with source and target as declared on Trip.members. Declare each operation's rules on one of the two fields: the same operation ruled on both is a model error (combine with AND where both must pass); different operations may sit on different sides.
  • A relationship failing READ_EDGE reads its properties as FORBIDDEN. Unless the claims alone settle the rule, nothing may filter, sort or aggregate by that relationship's properties.
  • Deleting a node removes its relationships without DISCONNECT rules: who may delete the node is the type's DELETE rule.
type Membership @relationshipProperties {
  role: String
    @authorization(
      validate: [
        {
          operations: [CREATE, UPDATE]
          where: { jwt: { roles: { includes: "admin" } } }
        }
      ]
    )
}

The smart layer

S1: indexes from the API. requirements() derives every constraint and index the API needs, with the reason for each:

| API declares | Needs | | -------------------------------------- | --------------------------------------- | | @key | node key constraint | | @unique | uniqueness constraint | | non-null @sortable field | existence constraint | | EQ, IN | nothing: LoraDB indexes equality lazily | | LT, LTE, GT, GTE, @sortable | RANGE index | | CONTAINS, STARTS_WITH, ENDS_WITH | TEXT index | | WITHIN_BBOX, DISTANCE | POINT index | | @fulltext, @vector | FULLTEXT and VECTOR indexes, by name |

assertSchema() reports what the database lacks (missing), and a full-text or vector index present under its name but defined differently (labels, fields, analyzer: mismatched), since search would keep using the old definition. assertSchema({ create: true }) creates what is missing and drops and re-creates what is mismatched (created, recreated), idempotently. check() fails on either.

S2: plans are checked. Every compiled statement records the access path it was written for. lora.explain(query, variables) plans each statement and reports a label scan where a seek was expected, a mutating plan behind a read, or result columns that do not match. The access path checked is the root field's own, outside every CALL { }: a label scan inside one (a @cypher statement's MATCH) is reported as a lint-level notes entry, not blamed on the root. lora.check({ operations }) runs this over your operations; the CLI does it in CI.

S3: compile once. lora.persist({ id: source }) parses and validates persisted operations at startup, and lora.execute({ id, variables, context }) runs them with no parsing or validation. Ad hoc documents passed to execute({ source }) are cached too. A subscription runs the same way with lora.subscribe({ id | source, variables, context }), which returns an async iterable of results (end it with context.signal); execute() answers a subscription with an error pointing there. Translation itself takes about 0.13 ms for a nested page, and statement text depends only on the shape of the input, so LoraDB's own plan cache is hit for every repeat.

S5: read-sets and write-sets. See change tracking.

S6: statistics and cost. A relationship filter that names a related node by key starts from that node and expands, instead of scanning the label. Every operation has a cost estimate (rows touched, multiplying page sizes through nested lists, capped by @cardinality), summed across its root fields, and an operation over maxCost (default 50 000) fails with COST_EXCEEDED before it runs. lora.analyze() counts nodes and measures relationship degrees: a nested list is then estimated at its relationship's maximum degree, measured over every node and capped by the page size, instead of the page size alone. Not a percentile: the caller picks the parents (by key, or by following a hub), so any lower bound is one it can exceed at will.

Filters are charged the rows they examine, not only the rows they return:

  • a root filter no index answers (contains, endsWith, caseInsensitive, NOT, OR, a computed field) costs the label's node count, or the page size without statistics; an equality, range, prefix or point predicate seeks and costs nothing extra;
  • each relationship a filter follows (some, none, all, single, count, aggregate, connection filters, single relationships) costs the related nodes it visits per candidate, multiplied per level: the mean degree over a scanned label, the maximum degree from parents the caller picked (by key, or the parents of a nested list), the default page size without statistics;
  • totalCount and aggregates read every match: the label's node count when no key narrows them.

`maxFilterDept