standards
SDK

Relations & documents

Linking objects with relation(), attaching file packs with document(), bilateral sync, and qualified edge properties via qualifyWith().

relation() links a record to one or more other records; document() attaches a pack of files. Both are reference-type attributes, and both accept .qualifyWith() to carry extra fields on the link itself rather than on the target.

Relations

import { relation } from "@stndrds/schema";

// Single relation (cardinality: "one")
relation({ name: "company", label: "Company" })
  .to("companies")
  .required()

// Multi relation (cardinality: "many")
relation({ name: "contacts", label: "Contacts" })
  .to("contacts")
  .many()

// Polymorphic: can point at either object type
relation({ name: "linkedTo", label: "Linked To" })
  .to("companies")
  .to("contacts")
  .many()

// Universal: can point at any object in the tenant
relation({ name: "related", label: "Related" })
  .toAny()
  .many()

.to(objectName, options?) adds a target object; call it more than once for a polymorphic relation. .many() converts a single relation to a multi relation (cardinality "many"), carrying over everything already configured — targets, required, hidden, and so on. .toAny() replaces the target list with a universal wildcard that can point at any object in the tenant. options on .to() accepts displayTemplate (a mustache template for how the target renders in pickers) and filter (restricts which records of that target are selectable).

Bilateral sync

A relation is one-directional by default: contacts.company points at a company, but nothing on the companies object reflects it back. .bilateral(config) keeps both sides in sync — but it isn't a one-line auto-generation of the inverse field. Both objects declare their own relation attribute, each telling the sync where its counterpart lives:

const COMPANY = object({ name: "companies", label: "Company" })
  .labelExpression("{{ name }}")
  .attribute(text({ name: "name", label: "Name" }).required())
  .attribute(
    relation({ name: "employees", label: "Employees" })
      .to("employees")
      .many()
      .bilateral({ object: "employees", attribute: "company" })
  );

const EMPLOYEE = object({ name: "employees", label: "Employee" })
  .labelExpression("{{ name }}")
  .attribute(text({ name: "name", label: "Name" }).required())
  .attribute(
    relation({ name: "company", label: "Company" })
      .to("companies")
      .bilateral({ object: "companies", attribute: "employees" })
  );

companies.employees says its inverse lives on employees.company; employees.company says its inverse lives on companies.employees. Whichever side gets written, the runtime keeps the other side's link consistent. .bilateral() must be called after .to(), and it isn't supported for polymorphic relations (more than one .to() target) or universal relations (.toAny()) — a bilateral pairing needs exactly one object on each side.

Qualified properties

.qualifyWith(...builders) — called after .to() — adds fields that describe the link itself, not the target record. Think "this contact is a member of this company, with role admin and 40 shares" — role and shares belong to the membership, not to the contact or the company.

import { relation, select, number } from "@stndrds/schema";

relation({ name: "members", label: "Members" })
  .to("contacts")
  .many()
  .qualifyWith(
    select({ name: "role", label: "Role" })
      .options([
        { value: "admin", label: "Admin" },
        { value: "member", label: "Member" },
      ])
      .required(),
    number({ name: "shares", label: "Shares" }).min(0),
  )

Qualifying properties live on the relationship itself, never as a field on the target record. Only scalar attribute builders are accepted as properties — text, number, checkbox, date, phone, currency, status, select, multiselect, and location — not another relation, document, user, formula, or rollup.

Documents

document() attaches a pack of files to a record — for a contract, an ID card, a set of invoice attachments.

import { document } from "@stndrds/schema";

document({ name: "contracts", label: "Contracts" })
  .accepts(["application/pdf"])
  .maxFileSize(10 * 1024 * 1024) // 10 MB per file
  .required()

.accepts(mimeTypes) restricts every file in the pack to the given MIME types; .maxFileSize(bytes) caps each file's size. Like relations, document() accepts .qualifyWith() to attach properties per document in the pack:

document({ name: "contracts", label: "Contracts" })
  .qualifyWith(
    select({ name: "kind", label: "Kind" }).options([
      { value: "master", label: "Master agreement" },
      { value: "amendment", label: "Amendment" },
    ]),
    number({ name: "amount", label: "Amount" }).min(0),
  )

Filtering on qualified properties

A view's FilterRule can target a qualified property instead of a plain attribute by setting property alongside attribute. The attribute must be a relation or document attribute that has .qualifyWith(); the operator must be valid for the property's type, not the reference attribute's type:

{
  combinator: "and",
  rules: [{ attribute: "members", property: "role", operator: "is", value: "admin" }],
}

This filters records down to those with at least one qualified edge where role is "admin" — the same filter mechanics as any other attribute, just resolved against the edge instead of the record.