Multi-Tenancy
How siteId scoping works, why it's non-negotiable, and how it's enforced structurally.
Toast is a multi-tenant platform. Every instance serves multiple independent sites from a single database. The entire data isolation model rests on one rule: every table has siteId, every query filters by it.
This is not a convention you remember to follow — it's structurally enforced.
How it works
Every database table has a site_id foreign key referencing the sites table:
export const content = pgTable(
'content',
{
id: uuid('id').defaultRandom().primaryKey(),
siteId: uuid('site_id')
.notNull()
.references(() => sites.id),
// ... domain columns
},
(table) => [index('idx_content_site_id').on(table.siteId)]
);Every repository method receives siteId and filters by it — no exceptions:
export function createContentRepository(db: PostgresJsDatabase) {
return {
findBySiteId: (siteId: string) => db.select().from(content).where(eq(content.siteId, siteId)),
findById: (siteId: string, id: string) =>
db
.select()
.from(content)
.where(and(eq(content.siteId, siteId), eq(content.id, id))),
};
}The siteId flows in from the session middleware, which extracts it from the authenticated user's session and stores it in Hono's context:
// Middleware sets it once
c.set('siteId', session.siteId);
// Controllers extract it once
const { siteId } = getAuthContext(c);
// Services receive it as a parameter
await contentService.createContent(input, siteId);
// Repositories use it in every query
.where(eq(content.siteId, siteId))Why it's non-negotiable
A missing siteId filter is a data leak — Site A's content becomes visible to Site B. This is a security bug, not a style issue.
The reason Toast enforces this structurally rather than by convention:
td generate feature— the repository template hassiteIdscoping baked in. It is structurally impossible to generate a repository without it.- Code review — any repository method missing a
siteIdfilter is a blocking review comment. - The type system — repositories are typed with
siteId: stringparameters. A method that doesn't acceptsiteIdis a signal something is wrong.
siteId in the schema
Every table definition must follow this pattern:
export const myTable = pgTable(
'my_table',
{
id: uuid('id').defaultRandom().primaryKey(),
siteId: uuid('site_id')
.notNull()
.references(() => sites.id), // required
// ...
},
(table) => [index('idx_my_table_site_id').on(table.siteId)] // required
);The index is not optional — queries without it will do full table scans on large datasets.
The one exception: global tables
A small number of tables exist outside the multi-tenancy model:
sites— the tenant registry itselfmigrations— Drizzle migration tracking
These don't have siteId because they are the site-level or system-level primitives. Every other table does.
Cross-site operations
There are no legitimate cross-site data operations in the application layer. If you find yourself needing to query across sites, that's a signal the operation belongs in a system-level migration script or admin tool, not in a service method.