Many-to-Many API
Link management across a join table
Relation declaration
export interface RelationConfig { type: RelationType; target: () => any; property: string; foreignKey?: string; inverseKey?: string; joinTable?: string;}RelationType is a numeric enum: OneToOne, OneToMany, ManyToOne, ManyToMany. A many-to-many relation is declared entirely by name — there is no through field and no per-column pivot definition. The join table has exactly two key columns.
Required fields:
- joinTable Name of the join table
- foreignKey Key column pointing at this model
- inverseKey Key column pointing at the target model
All three are required at runtime.
For a ManyToMany relation, joinTable, foreignKey and inverseKey must all be present. If any is missing, the link methods throw a StabilizeError with code === "RELATION_ERROR".
autoMigrate does not create join tables.
autoMigrate only reads a model's columns, and the join table has no model. Create it yourself — a migration, raw SQL, or the stabilize-cli migration flow — or the link methods will fail because the table does not exist.
attach()
async attach( id: number | string, relation: string, targetIds: number | string | (number | string)[], _client?: DBClient,): Promise<number>Adds links and returns how many were created. Dedupes the ids and is idempotent — ids that are already linked are not counted or written twice.
Parameters:
- id Primary key of the owning record
- relation The relation's
propertyname, e.g."tags"— not the table name. It must resolve to aManyToManyrelation or the call throwsRELATION_ERROR. - targetIds One id or an array of ids to link
- _client Optional
DBClient(optional)
await postRepo.attach(20, "tags", [1, 2]); // => 2await postRepo.attach(20, "tags", 1); // => 0 (already linked)await postRepo.attach(20, "tags", ["3"]); // => 1 ("3" and 3 are the same id)detach()
async detach( id: number | string, relation: string, targetIds?: number | string | (number | string)[], _client?: DBClient,): Promise<number>Removes links and returns how many were removed. Omit targetIds to unlink all links for the record.
Parameters:
- id Primary key of the owning record
- relation The relation's
propertyname - targetIds Ids to unlink; omit to unlink everything (optional)
- _client Optional
DBClient(optional)
await postRepo.detach(20, "tags", [1]); // => 1await postRepo.detach(20, "tags"); // => 1 (all remaining links)sync()
async sync( id: number | string, relation: string, targetIds: (number | string)[], _client?: DBClient,): Promise<{ attached: number; detached: number }>Makes the link set exactly equal to targetIds: missing ids are attached, extra ids are detached. Runs inside a transaction. Takes an array only — it is the exact final set, not a delta.
Parameters:
- id Primary key of the owning record
- relation The relation's
propertyname - targetIds The exact final set of ids (array only)
- _client Optional
DBClient(optional)
// Starting state: post 20 links tags 1 and 2.const result = await postRepo.sync(20, "tags", [1, 2, 3]);// { attached: 1, detached: 0 }// The post now links exactly 1, 2 and 3.Ids only
There is no pivot data.
All three methods take ids only. There is no argument for extra columns on the link, so you cannot write a timestamp, an ordering column, or any other pivot attribute onto a relationship.
Ids are compared as strings, so 1 and "1" refer to the same link. attach and sync dedupe the ids they are given before writing.
const postRepo = orm.getRepository(Post);
await postRepo.attach(20, "tags", [1, 2]);await postRepo.detach(20, "tags", 1);const { attached, detached } = await postRepo.sync(20, "tags", [2, 3]);
// All of these require the relation to be ManyToMany and to declare// joinTable, foreignKey and inverseKey -- otherwise: RELATION_ERROR.// Remember: create the join table yourself; autoMigrate will not.