Standards Docs
Guides

Backend query builder

Use typed fluent record queries from NestJS services.

The backend query builder gives NestJS services a typed, fluent API for reading and writing schema records. Inject an object with @InjectObject(objectName), type it with your record values, then chain filters, sorting, pagination, and terminal methods.

// @noverify
import { Injectable } from "@nestjs/common";
import { InjectObject, type Objects } from "@stndrds/adapter-nestjs";

interface ContactValues {
  firstName: string;
  lastName: string;
  email?: string;
  stage?: "lead" | "customer";
  lastContactedAt?: string;
}

@Injectable()
export class ContactService {
  constructor(
    @InjectObject("contacts")
    private readonly contacts: Objects<ContactValues>
  ) {}

  findCustomers() {
    return this.contacts
      .eq("stage", "customer")
      .orderBy("lastName", "asc")
      .limit(50)
      .fetch();
  }
}

Enable object providers before injecting them. In SchemaModule.forRoot, set objects: true to register providers for every object, or objects: ["contacts"] to register only selected object providers. Without that option, Nest cannot resolve @InjectObject("contacts").

Filtering

Filters are immutable. Each call returns a new builder with the added rule.

MethodUse
.where(attribute, operator, value)Add an explicit filter operator.
.or()Combine all current filter rules with OR instead of the default AND.
.eq(attribute, value)Match records where the attribute equals a value.
.neq(attribute, value)Match records where the attribute does not equal a value.
.gt(attribute, value)Match values greater than the provided value.
.gte(attribute, value)Match values greater than or equal to the provided value.
.lt(attribute, value)Match values less than the provided value.
.lte(attribute, value)Match values less than or equal to the provided value.
.contains(attribute, value)Match text, relation, or list-like values containing the provided value.
.notContains(attribute, value)Exclude text, relation, or list-like values containing the provided value.
.startsWith(attribute, value)Match text values with the given prefix.
.endsWith(attribute, value)Match text values with the given suffix.
.isEmpty(attribute)Match empty values.
.isNotEmpty(attribute)Match values that are present.
.in(attribute, values)Match any value in the provided list.
.notIn(attribute, values)Exclude every value in the provided list.

Use .where() when you already have the schema filter operator, and shortcut methods when you want a readable service method.

// @noverify
const recentLeads = await this.contacts
  .where("stage", "is", "lead")
  .gte("lastContactedAt", "2026-01-01T00:00:00.000Z")
  .fetch();

const namedAdaOrGrace = await this.contacts
  .or()
  .eq("firstName", "Ada")
  .eq("firstName", "Grace")
  .fetch();

Search and grouping

Use .search(query) for full-text search when your database adapter is configured with a search adapter. You can combine search with filters, sorting, and pagination.

Use .groupBy(attribute) with .fetchGrouped() when you need grouped results for Kanban-style views or status lanes.

Use .raw() when you need the underlying ObjectRecord instead of formatted values. Raw records include object IDs, internal labels, completion status, metadata, and other system fields.

// @noverify
const searchResults = await this.contacts
  .search("ada")
  .eq("stage", "customer")
  .limit(20)
  .fetch();

const byStage = await this.contacts
  .groupBy("stage")
  .fetchGrouped();

const raw = await this.contacts
  .raw()
  .findById("record-id");

Sorting and pagination

Use .orderBy(attribute, direction) for sorting. The direction is "asc" by default and can be "desc".

Use .limit(count) and .offset(count) for pagination. They apply to .fetch(), .count(), and grouped reads.

// @noverify
const page = await this.contacts
  .orderBy("lastName", "asc")
  .limit(25)
  .offset(50)
  .fetch();

Reading records

MethodUse
.fetch()Return { records, total } for the current query.
.single()Return exactly one record. Throws when zero or multiple records match.
.first()Return the first matching record, or null when none match.
.findById(id)Return a record by ID, or null when it does not exist.
.count()Return the total number of matching records.
.fetchGrouped()Return grouped records after .groupBy(attribute).
// @noverify
const result = await this.contacts.eq("stage", "customer").fetch();
const contact = await this.contacts.eq("email", "ada@example.com").single();
const maybeContact = await this.contacts.eq("email", "missing@example.com").first();
const totalCustomers = await this.contacts.eq("stage", "customer").count();

Writing records

MethodUse
.insert(values, options?)Create a record.
.update(values, options?)Update the single record matched by the query.
.upsert(values, options?)Update by id when present and found, otherwise insert.
.delete()Delete the single record matched by the query.

update() and delete() expect exactly one matched record. Add a unique schema-attribute filter, such as .eq("email", email), before calling them. Use .findById(id) when you need ID lookup.

// @noverify
const created = await this.contacts.insert({
  firstName: "Ada",
  lastName: "Lovelace",
  email: "ada@example.com",
  stage: "lead",
});

const updated = await this.contacts
  .eq("email", "ada@example.com")
  .update({ stage: "customer" });

await this.contacts
  .eq("email", "ada@example.com")
  .delete();

Metadata updates

Record metadata is developer-managed data stored alongside values. Pass metadata through the write options.

// @noverify
await this.contacts
  .eq("email", "ada@example.com")
  .update(
    { stage: "customer" },
    { metadata: { syncedToCrm: true, crmId: "crm-123" } }
  );

Metadata updates are merged with existing metadata. Set a metadata key to undefined when you need to remove it.

Tenant and actor scope

Most NestJS requests get tenant and actor context from SchemaModule request handling. Use .tenant(tenantId) and .actor(actorId) only when backend code needs to override that AsyncLocalStorage context explicitly, such as a trusted background job or a controlled cross-tenant maintenance task.

// @noverify
const records = await this.contacts
  .tenant("tenant-id")
  .actor("actor-id")
  .eq("stage", "customer")
  .fetch();

tenant() scopes the query to the given tenant. actor() sets the audit and permission actor for the operation. When you provide both, the builder executes inside that explicit scope.

On this page