Many-to-Many
Link, unlink and reconcile rows across a join table. attach, detach and sync manage the links; the rows on either side are untouched.
Declaring the Relation
A many-to-many relation names the join table and the two columns inside it. There is no pivot model — the join table is declared by name only, with exactly two keys.
import { defineModel, DataTypes, RelationType } from "stabilize-orm";
export const Post = defineModel({ tableName: "posts", columns: { id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true }, title: { type: DataTypes.STRING }, }, relations: [ { type: RelationType.ManyToMany, target: () => Tag, property: "tags", // how you name it in code joinTable: "post_tags", // the table holding the links foreignKey: "post_id", // this model's column in the join table inverseKey: "tag_id", // the other model's column }, ],});Create the Join Table
autoMigrate does not create join tables
AutoMigrate reads a model's columns — the join table has no model, so nothing generates it. Every attach/detach/sync call will fail with RELATION_ERROR until the table exists. Declare it in a migration, or create it up front:
{ "name": "create_post_tags", "up": "CREATE TABLE post_tags (post_id INTEGER, tag_id INTEGER)", "down": "DROP TABLE post_tags"}Give the two columns a composite primary key or a unique index if you want the database itself to reject duplicate links. Stabilize already dedupes within a single call, but two concurrent attach calls can still race.
attach
Adds links. Accepts a single id or an array, dedupes the input, and returns how many links it created — re-attaching an existing link is a no-op, not an error, so attach is safe to call twice.
const postRepo = orm.getRepository(Post);
await postRepo.attach(20, "tags", [1, 2]); // => 2await postRepo.attach(20, "tags", [1, 2]); // => 0 (already linked)await postRepo.attach(21, "tags", 1); // => 1 (single id is fine)detach
Removes links. Omit the third argument to unlink everything — an easy thing to do by accident, so pass an explicit list unless that is what you mean.
await postRepo.detach(20, "tags", [2]); // => 1await postRepo.detach(21, "tags", 1); // => 1await postRepo.detach(20, "tags"); // => 2 (unlinks ALL tags)sync
Makes the links match an exact set — attaching what is missing and detaching what is extra, in one transaction. This is what you want behind a checkbox list, where the client posts the complete selection and the server reconciles.
await postRepo.sync(21, "tags", [2, 3]);// => { attached: 2, detached: 1 }
await postRepo.sync(21, "tags", []);// => { attached: 0, detached: 2 } (clears all links)app.put("/posts/:id/tags", async (req, res) => { const { tags } = req.body; // [1, 4, 7] - the full selection const result = await postRepo.sync(Number(req.params.id), "tags", tags); res.json(result);});Reading the Links
Load the related rows through relations on any finder. The relation is named by its property, not the table.
const post = await postRepo.findOne(20, { relations: ["tags"] });console.log(post.tags.map((t) => t.name));API Reference
attach( id: number | string, relation: string, targetIds: number | string | (number | string)[], client?: DBClient,): Promise<number> // links created
detach( id: number | string, relation: string, targetIds?: number | string | (number | string)[], // omit = unlink all client?: DBClient,): Promise<number> // links removed
sync( id: number | string, relation: string, targetIds: (number | string)[], // array only client?: DBClient,): Promise<{ attached: number; detached: number }>Links carry no extra data
All three take ids only. There is no way to write an ordering column, a timestamp or any other field onto the link itself — the join table is two keys and nothing else. If your pivot needs its own columns, model it explicitly: create a real model for the join table with two ManyToOne relations and treat it as an entity.