Backend data access
Read and write records from NestJS services with the injected, typed query builder.
On the server you don't call the REST API — you inject a typed query builder for an object and use it directly. Same validation, permissions, and lifecycle as the API, without HTTP.
Setup
Register a provider per object you want to query, then inject it with @InjectObject:
import { Module } from "@nestjs/common";
import { createObjectProviders } from "@stndrds/adapter-nestjs";
@Module({
providers: [...createObjectProviders(["contacts", "deals"]), DealAssignmentService],
})
export class DealsModule {}Then inject the builder in any provider of that module:
import { Injectable } from "@nestjs/common";
import { InjectObject, type Objects } from "@stndrds/adapter-nestjs";
interface ContactValues {
firstName: string;
lastName: string;
email?: string;
status?: string;
}
@Injectable()
export class DealAssignmentService {
constructor(
@InjectObject("contacts")
private readonly contacts: Objects<ContactValues>,
) {}
}Objects<T> takes the shape of the record's attribute values — the fields you defined in the schema, without system fields like id or createdAt. Instead of writing the interface by hand, derive it from your schema package with ExtractRecordInput<typeof CONTACT>.
Reading records
Chain filters, then end with a terminal method — the call that executes the query and returns results:
// One record or null
const contact = await this.contacts.eq("email", "[email protected]").first();
// A page of records with a total
const { records, total } = await this.contacts
.eq("status", "active")
.orderBy("lastName", "asc")
.limit(50)
.fetch();
// By id, count, full-text search
const one = await this.contacts.findById(recordId);
const activeCount = await this.contacts.eq("status", "active").count();
const hits = await this.contacts.search("lovelace").limit(10).fetch();Filter methods cover the same ground as the API's filter operators, with builder-style names: eq, neq, gt, gte, lt, lte, contains, notContains, startsWith, endsWith, isEmpty, isNotEmpty, in, notIn, plus the generic where(attribute, operator, value) which takes the canonical API operator names. Each sugar method emits the canonical operator (eq → is, gt → greater_than, in → any_of, …). Before the terminal call you can also chain modifiers: orderBy, limit, offset, search, and withDeleted() to include trashed records.
Terminal methods:
| Method | Returns |
|---|---|
fetch() | { records, total } — records is the current page, total counts every match, ignoring limit and offset |
first() | The first match, or null. No ordering is applied unless you call orderBy, so add one when "first" has to mean something |
single() | Exactly one match — throws QueryNoResultError on zero, QueryMultipleResultsError on several |
findById(id) | The record, or null |
count() | The number of matches |
Records come back flat
The REST API nests attribute values under values. The query builder returns them at the top level, merged with system fields such as id and createdAt:
const contact = await this.contacts.eq("email", "[email protected]").first();
contact?.firstName; // string — no .values indirection
contact?.createdAt; // DateA record from the query builder carries your attributes plus id, createdAt, updatedAt, and metadata — and nothing else. label and objectId are API-only: reading them off a builder record gives you undefined. Go through the REST API when you need them.
Writing records
Writes use the same builder. Filter first for updates and deletes, then call the write method:
// Create — same validation as the API: a missing required field throws
const created = await this.contacts.insert({ firstName: "Ada", lastName: "Lovelace" });
// allowDraft skips the required-field check and stores an incomplete record
const draft = await this.contacts.insert({ firstName: "Draft" }, { allowDraft: true });
// Update — filter to exactly one record first
await this.contacts.eq("id", contactId).update({ status: "active" });
// Soft delete — also expects exactly one match
await this.contacts.eq("id", contactId).delete();update() and delete() resolve the target with single() before writing, so their filters must match exactly one record: zero throws QueryNoResultError, several throw QueryMultipleResultsError. The standard pattern is .eq("id", …) first. There is no bulk form — to change many records, fetch them and write in a loop, or go through the bulk endpoints.
Both insert and update accept a metadata option — a place for developer-managed data (external IDs, sync markers) stored outside the record's attributes.
Inside triggers
Triggers are regular NestJS providers, so the same injection works there — the standard way for a trigger to read or write other records:
import { Injectable } from "@nestjs/common";
import {
BeforeCreate,
InjectObject,
type Objects,
Triggers,
type TriggerContext,
} from "@stndrds/adapter-nestjs";
@Injectable()
@Triggers()
export class DealTriggers {
constructor(
@InjectObject("contacts")
private readonly contacts: Objects<ContactValues>,
) {}
@BeforeCreate("deals", "*")
async assignOwner(ctx: TriggerContext) {
const { newValue: contactId } = ctx.getChange<string>("contact");
if (!contactId) return;
const contact = await this.contacts.eq("id", contactId).first();
if (contact?.status !== "active") {
throw new Error("Deals can only be created for active contacts");
}
}
}To change the record being written, or to reach the data layer when the object name is only known at runtime, see Triggers.
Scoping
In single-tenant mode the injected builder is a singleton, available from startup. In multi-tenant mode it is request-scoped: it resolves the workspace from the incoming request. Any provider that injects it becomes request-scoped too, so it cannot be used outside a request — in onModuleInit or other startup code, for example.