standards
Relations and documentsData model

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 "@standards/client";

export const CONTACT = object("contacts", "Contact")
  .attribute(text("name", "Name").required())
  .attribute(relation("company", "Company").to("companies"))
  .attribute(relation("peers", "Peers").to("contacts").many().maxItems(20))
  .attribute(
    document("idCards", "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("members", "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("linkedTo", "Linked to").to("companies").to("contacts").many();
relation("related", "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 "@standards/client";

relation("members", "Members")
  .to("contacts")
  .many()
  .qualifyWith(
    select("role", "Role")
      .options([
        { value: "admin", label: "Admin" },
        { value: "member", label: "Member" },
      ])
      .required(),
    number("shares", "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 "@standards/client";

export const COMPANY = object("companies", "Company")
  .attribute(text("name", "Name").required())
  .attribute(
    relation("employees", "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.

Adding .bilateral() to a relation that already holds links shows those links on the inverse as soon as the source is applied, and filters on the inverse see them too. One exception: when the inverse is single-valued, a record that already holds a link there, or that several links point to, keeps only what it had. Declaring the inverse does not save the records that gain a link: their version, the values computed from their relations and workspace search catch up on each record's next save.

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, query } from "@standards/client";

content.collection(
  query("companies").where({ attribute: "members", property: "role", operator: "is", value: "admin" })
).table();

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

A location property is stored and returned but not filterable: a rule has no field to name one of its parts, and the records API refuses a rule on the whole location. The filter picker does not offer it.

Qualified properties in a form prefill

A form's free field (form() from @standards/schema, on the runtime, not this SDK) can take a prefillExpression that reads a slot's relation: {{ slot.relation.label }} renders one label per edge, | join:"\n" chooses the separator, and the formatting pipes (UPPER, LOWER, capitalize, trim, prefix, suffix, wrap, default) apply to each line. A qualified relation adds two things.

where: keeps the edges whose qualified property matches. It runs before the edges are labelled and joined, and every line keeps the relation's label.

{{ declarant.relationships.label | where:relationType=notary | join:"\n" }}
{{ declarant.relationships.label | where:relationType in (notary,tax_advisor,accountant) | join:"\n" }}
{{ declarant.relationships.label | where:relationType=notary | where:engagement=current }}
  • where:property=value keeps one value; where:property in (a,b,c) keeps any of them. The lines follow the relation's order, not the list's.
  • Several where: pipes combine with AND.
  • The comparison is exact and case-sensitive, on the stored value: a select option's value (tax_advisor), never its label (Fiscaliste). Spaces around =, in, the parentheses and the commas are ignored; a value cannot contain spaces, quotes, ,, (, ), =, |, { or }.
  • When no edge matches, the block renders nothing.

A qualified property can be the sub-attribute. {{ slot.relation.property }} reads the link, not the target record, and renders the property's display value: a select option's label rather than its stored value.

{{ declarant.relationships.maritalPropertyRegime | where:relationType=spouse }}
{{ declarant.spouse.maritalPropertyRegime }}

With maritalPropertyRegime stored as communaute_reduite_acquets, both render its option label, Communauté réduite aux acquêts. A property declared by .qualifyWith() takes precedence over a target attribute of the same name.

A malformed where: — a missing = or in, unbalanced parentheses, an empty property or value — makes the form builder's build() throw, the way an unknown pipe does. A where: on a property the relation does not declare cannot be seen there, since the builder does not know the relation: it is caught when the prefill resolves, and the block renders nothing rather than every edge. So does a where: on a plain attribute, which has no edges, and a malformed where: in a form saved without the builder.

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 "@standards/client";

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

Qualified properties on a document describe each file of the pack. The bytes arrive through the SDK (standards.documents.attachToRecord), the CLI, MCP or REST, see Files and documents. A public file never joins a pack.

Next steps