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.

models/post.ts
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:

migrations/20260402120000_post_tags.json
{
"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.

attach.ts
const postRepo = orm.getRepository(Post);
await postRepo.attach(20, "tags", [1, 2]); // => 2
await 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.

detach.ts
await postRepo.detach(20, "tags", [2]); // => 1
await postRepo.detach(21, "tags", 1); // => 1
await 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.

sync.ts
await postRepo.sync(21, "tags", [2, 3]);
// => { attached: 2, detached: 1 }
await postRepo.sync(21, "tags", []);
// => { attached: 0, detached: 2 } (clears all links)
route.ts
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.

read.ts
const post = await postRepo.findOne(20, { relations: ["tags"] });
console.log(post.tags.map((t) => t.name));

API Reference

api.ts
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.