standards
Relations and documentsSDK

Relations and documents

Link records to each other with typed relations, keep both sides in step, and attach packs of files with rules on type and size.


relation() links a record to one or more records. document() attaches a pack of files. Both are reference attributes, and both take .qualifyWith() to carry fields on the link itself.

import { document, object, relation, text } from "@stndrds/client";

export const CONTACT = object({ name: "contacts", label: "Contact" })
  .attribute(text({ name: "name", label: "Name" }).required())
  .attribute(relation({ name: "company", label: "Company" }).to("companies"))
  .attribute(relation({ name: "peers", label: "Peers" }).to("contacts").many().maxItems(20))
  .attribute(
    document({ name: "idCards", label: "ID cards" })
      .accepts(["image/*", "application/pdf"])
      .maxFileSize(5 * 1024 * 1024)
  )
  .labelExpression("{{ name }}");

Cardinality and chain order

A relation starts single (cardinality: "one", stores one id or null). .many() returns a new builder with cardinality: "many". It carries the targets, required, hidden, description, icon, order, placeholder and metadata across, and nothing else. So the order is fixed: targets, then cardinality, then everything that describes the link.

relation({ name: "members", label: "Members" })
  .to("contacts")        // 1. targets
  .many()                // 2. cardinality: a new builder
  .maxItems(50)          // 3. many-only options
  .qualifyWith(/* ... */) // 4. link fields
  .bilateral({ attribute: "memberships" }); // 5. inverse

A .qualifyWith() or .bilateral() placed before .many() is dropped by the new builder without a warning. A lost inverse then surfaces on the workspace as a misleading Attribute X already exists.

Targets and polymorphism

.to(objectName, options?) adds a target. Call it more than once for a polymorphic relation. .toAny() replaces the list with the universal wildcard.

relation({ name: "linkedTo", label: "Linked to" }).to("companies").to("contacts").many();
relation({ name: "related", label: "Related" }).toAny().many();

options on .to() takes displayTemplate (how the target renders in a picker, "{firstName} {lastName}") and filter (which target records are selectable).

Qualified properties

.qualifyWith(...builders) adds fields that describe the link, not the target: a membership has a role and shares; the contact does not. It must come after .to(); without targets it throws.

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

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)
  );

Only scalar builders are accepted as properties: text, number, checkbox, date, phone, currency, status, select, multiselect, location. Not relation, document, user, formula or rollup.

Bilateral pairs

.bilateral({ attribute, label?, cardinality?, storageOwner? }) names the inverse attribute on the target. The target object is read from .to(), so the relation needs exactly one target: a polymorphic or universal relation throws.

import { object, relation, text } from "@stndrds/client";

export const COMPANY = object({ name: "companies", label: "Company" })
  .attribute(text({ name: "name", label: "Name" }).required())
  .attribute(
    relation({ name: "employees", label: "Employees" })
      .to("contacts")
      .many()
      .bilateral({ attribute: "company", label: "Company", cardinality: "one" })
  )
  .labelExpression("{{ name }}");
KeyMeaning
attributeName of the inverse attribute on the target object
labelLabel given to the inverse when the workspace generates it
cardinalityCardinality of the inverse; inferred when omitted
storageOwnertrue on the side that stores qualified properties; the other side reads them

The workspace generates the inverse when it is absent. Declaring it on both objects is allowed as long as both addresses and cardinalities agree; a mismatch is a validation error at sync time. Both objects must exist on the workspace when the source is applied. Writes commit the two directions in one transaction: a cardinality conflict rejects the whole write.

Deleting a referenced record

There is no on-delete option, and nothing is refused. Deleting a record moves it to the trash and moves every relation edge that points at it to the trash in the same transaction: the records on the other side stay, their link to the deleted record disappears. Restoring the record restores exactly those edges. Purging it drops them for good.

Filtering on qualified properties

A FilterRule targets a qualified property by setting property next to attribute. The operator must fit the property's type, not the relation's.

import { content } from "@stndrds/client";

content.collection().object("companies").table().filter({
  combinator: "and",
  rules: [{ attribute: "members", property: "role", operator: "is", value: "admin" }],
});

This keeps the records that have at least one link where role is "admin".

Attach a pack of files

document() attaches a pack of files to a record: a contract, an ID card, a set of invoices. .accepts(mimeTypes) restricts every file of the pack to the given prefixes or globs; .maxFileSize(bytes) caps each file.

import { document, number, select } from "@stndrds/client";

document({ name: "contracts", label: "Contracts" })
  .accepts(["application/pdf"])
  .maxFileSize(10 * 1024 * 1024)
  .required()
  .qualifyWith(
    select({ name: "kind", label: "Kind" }).options([
      { value: "master", label: "Master agreement" },
      { value: "amendment", label: "Amendment" },
    ]),
    number({ name: "amount", label: "Amount" }).min(0)
  );

Qualified properties on a document describe each file of the pack. The bytes arrive through the CLI, MCP or REST, see Files and documents.

Next steps