Many-to-Many API

Link management across a join table

Relation declaration

typescript
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()

typescript
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 property name, e.g. "tags" — not the table name. It must resolve to a ManyToMany relation or the call throws RELATION_ERROR.
  • targetIds One id or an array of ids to link
  • _client Optional DBClient (optional)
example/attach.ts
await postRepo.attach(20, "tags", [1, 2]); // => 2
await postRepo.attach(20, "tags", 1); // => 0 (already linked)
await postRepo.attach(20, "tags", ["3"]); // => 1 ("3" and 3 are the same id)

detach()

typescript
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 property name
  • targetIds Ids to unlink; omit to unlink everything (optional)
  • _client Optional DBClient (optional)
example/detach.ts
await postRepo.detach(20, "tags", [1]); // => 1
await postRepo.detach(20, "tags"); // => 1 (all remaining links)

sync()

typescript
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 property name
  • targetIds The exact final set of ids (array only)
  • _client Optional DBClient (optional)
example/sync.ts
// 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.

example/many-to-many.ts
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.