Skip to content

Relations (M2M)

RelationBuilder manages the entries in a many-to-many pivot table. It is accessed via pb.model(ModelClass, parentId).

// Access the relation builder for a specific record
const room = pb.model(Room, roomId);
// room.<relationName>() returns a RelationBuilder
await room.members().attach(userId);

The relation name (e.g. members) must match a property on the model decorated with @relation({ type: 'many-to-many', ... }).

@relation({
type: 'many-to-many',
target: () => User,
joinTable: 'room_members', // pivot table name
foreignKey: 'room_id', // FK pointing to the parent (Room)
inverseJoinColumn: 'user_id', // FK pointing to the related (User)
pivotColumns: ['role'], // Extra columns to include from the pivot
})
members: User[];

Add one or more records to the relation.

// Single ID
await room.members().attach(userId);
// With pivot data
await room.members().attach(userId, { role: 'admin' });
// Multiple IDs
await room.members().attach([userId1, userId2, userId3]);
// Multiple with individual pivot data
await room.members().attach({
[userId1]: { role: 'admin' },
[userId2]: { role: 'member' },
[userId3]: { role: 'member' },
});

Duplicate safe — if the relation already exists, the database ON CONFLICT DO NOTHING prevents errors.


Remove one or more records from the relation. If no IDs are provided, removes all related records.

// Remove a specific member
await room.members().detach(userId);
// Remove multiple members
await room.members().detach([userId1, userId2]);
// Remove ALL members
await room.members().detach();

Replace the current relation set. Members not in the new list are detached; those in the list but not attached yet are attached.

// The new exact set of members will be exactly [u1, u2, u3]
await room.members().sync([userId1, userId2, userId3]);
// With pivot data per record
await room.members().sync({
[userId1]: { role: 'admin' },
[userId2]: { role: 'member' },
});

Use sync when you have a definitive “current state” list (e.g., saving a form with a checkboxes).


Update the extra columns on a specific pivot row without changing the relation itself.

// Promote a user to admin
await room.members().updatePivot(userId, { role: 'admin' });
// Update multiple fields
await room.members().updatePivot(userId, { role: 'moderator', joined_at: new Date().toISOString() });

Attach if not present; detach if already in the relation.

// Toggle a single user
await room.members().toggle(userId);
// Toggle multiple users
await room.members().toggle([userId1, userId2]);
// Toggle with pivot data for new attachments
await room.members().toggle({
[userId1]: { role: 'member' },
});

Use toggle for like/follow/join buttons where a single action should alternate between attach and detach.


When the inverseJoinColumn value matches jwt.sub (the current user’s ID), the relation is JWT-bound — the database automatically validates that users can only manage their own memberships via RLS.

// The current user joins a room
await room.members().attach(pb.auth.getCachedUser()!.sub, { role: 'member' });
// The current user leaves a room
await room.members().detach(pb.auth.getCachedUser()!.sub);

Combined with an RLS policy using owner: 'user_id', this makes it impossible for any user to add or remove someone else via the API — enforced at the PostgreSQL level.