s-m-r-t
Open the s-m-r-t source on GitHub Switch to dark color scheme
← Reference

Reference

Typed relationships between profiles

A ProfileRelationship is one directed row from one profile to another with a required type. A reciprocal type can write the inverse row for you, a third profile can give the pair context, and terms date it.

Verified against s-m-r-t 0.44.0

One row, one direction

ProfileRelationship stores fromProfileId, toProfileId, and typeId as required foreign keys. Direction is part of the row: a supplier link from a mill to a shop is not one from the shop to the mill. The generated surfaces are REST list, get, create, and delete, MCP list and get, and the CLI.

  • fromProfileId and toProfileId reference Profile, so any subtype works: Person, Organization, or Bot.
  • typeId references ProfileRelationshipType and must resolve; addRelationship throws when the slug has no type row.
  • contextProfileId is an optional third Profile reference, and terms is a oneToMany to ProfileRelationshipTerm.
  • tenantId is nullable with optional scope. Under an active tenant context the row belongs to that tenant; with no context it stays global.

Types decide whether the inverse exists

A ProfileRelationshipType has a slug, a name, and a reciprocal flag that defaults to true. Nothing seeds type rows; create them with getOrCreateBySlug. When addRelationship saves a row for a reciprocal type, it looks up a handler by slug and runs it. The default handlers cover friend, spouse, partner, colleague, and sibling, and each calls addRelationship in the other direction. A reciprocal type with no handler writes one row only.

  • addRelationship checks exists(from, to, type) first, so repeating the call writes nothing and runs no handler.
  • registerReciprocalHandler(slug, handler) adds or replaces a handler. The handler receives the from profile, the to profile, and the optional context profile.
  • removeRelationship deletes the forward rows and, for a reciprocal type, the inverse rows of the same type, whether or not a handler exists. A handler that wrote a different type leaves that row for you to remove.
  • Type rows carry no tenant column. Use the collection getBySlug for lookups; the static ProfileRelationshipType.getBySlug returns null.
relationship-types.ts
typescript
import {
  ProfileRelationshipType,
  ProfileRelationshipTypeCollection
} from '@happyvertical/smrt-profiles';

const types = await ProfileRelationshipTypeCollection.create({ db });

// Directional: the mill supplies the shop; the shop does not supply the mill.
await types.getOrCreateBySlug('supplier', { name: 'Supplier', reciprocal: false });

// Reciprocal with a shipped handler: adding one side adds the other.
await types.getOrCreateBySlug('partner', { name: 'Partner' });

// Reciprocal with your own handler: the inverse is a different type.
await types.getOrCreateBySlug('client', { name: 'Client' });
ProfileRelationshipType.registerReciprocalHandler('client', async (from, to, context) => {
  await to.addRelationship(from, 'supplier', context);
});

Read from either side

Profile.getRelationships takes typeSlug and direction. The from direction returns rows this profile wrote, to returns rows that point at it, and all returns both. getRelatedProfiles returns the profiles on the other end of every row in both directions, once each.

  • getRelationshipsFrom() and getRelationshipsTo() are the generated oneToMany accessors; they return raw rows with no slug filter.
  • ProfileRelationshipCollection has getFromProfile, getToProfile, getForProfile, and exists, each taking an optional type id rather than a slug.
  • ProfileCollection.getRelationshipNetwork(profileId, { maxDepth }) walks related profiles breadth first and returns a Map from profile id to depth, starting with the seed profile at 0. The default depth is 2, and every type counts.
  • ProfileRelationship.getTypeSlug() loads the type and returns its slug, or an empty string when the type is missing.
  • A typeSlug that matches no type row is not an error: getRelationships passes an undefined type id and returns every relationship, and getRelatedProfiles inherits that.
read-relationships.ts
typescript
import { ProfileCollection } from '@happyvertical/smrt-profiles';

const profiles = await ProfileCollection.create({ db });
const shop = await profiles.get('edmonton-shop');
if (!shop?.id) throw new Error('missing organization');

// Rows that point at the shop with the supplier type.
const supplierRows = await shop.getRelationships({ typeSlug: 'supplier', direction: 'to' });

// The organizations on the other end of every partner row, once each.
const partners = await shop.getRelatedProfiles('partner');

// The shop at depth 0 plus every profile within two hops, by any type.
const network = await profiles.getRelationshipNetwork(shop.id, { maxDepth: 2 });

Context names a third profile

The third argument to addRelationship is a context profile, stored as contextProfileId. The default friend, partner, and colleague handlers carry it to the inverse row; the spouse and sibling handlers drop it. No shipped query filters by context, so read the field from the rows you get back.

context.ts
typescript
// Two people are colleagues in the context of one organization.
await alice.addRelationship(bob, 'colleague', acme);

const [row] = await alice.getRelationships({ typeSlug: 'colleague', direction: 'from' });
row.contextProfileId; // acme.id

Terms date a relationship

A ProfileRelationshipTerm belongs to one relationship through relationshipId and records startedAt and an optional endedAt. A term is active when it has no end date or its end date is in the future. Terms carry no tenant column; they follow their relationship.

  • relationship.addTerm(startedAt, endedAt?) requires a saved relationship and throws without an id.
  • relationship.endCurrentTerm(endedAt) ends the first active term, and getActiveTerm() returns it or null.
  • term.end() defaults to now, and getDurationDays() measures to the end date, or to now while the term is open.
  • ProfileRelationshipTermCollection adds getByRelationship, getActiveTerm, and getHistoricalTerms for ended terms.
terms.ts
typescript
const [row] = await mill.getRelationships({ typeSlug: 'supplier', direction: 'from' });

await row.addTerm(new Date('2025-01-01'));
const active = await row.getActiveTerm(); // the open term

await row.endCurrentTerm(new Date('2025-12-31'));
const history = await row.getTerms(); // one ended term

What is not there

Relationships are between profiles, not tenants. Tenant in smrt-users has a name, a status, a description, the hierarchy fields, and the two cascade flags; it has no profile field. The only shipped link between the two packages is User.profileId. A relationship row carries one tenantId. A tenant-to-tenant partnership is therefore a pattern an application builds, for example one Organization profile per tenant, not a shipped feature. Permission resolution never reads a relationship, so relating two profiles changes nothing about who can see what.