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. inverseA .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 }}");| Key | Meaning |
|---|---|
attribute | Name of the inverse attribute on the target object |
label | Label given to the inverse when the workspace generates it |
cardinality | Cardinality of the inverse; inferred when omitted |
storageOwner | true 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=valuekeeps 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
selectoption'svalue(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
- Views: collections that follow a relation
- Attributes: the scalar builders a link can carry
- Files and documents: how a pack receives its files
- Records: write a relation value from the SDK
- Book a demo