-
Notifications
You must be signed in to change notification settings - Fork 0
Repository Reference
The Repository in src/db/repository.ts is the only object that touches tenant data, so it is the most important interface in the project to understand fully. This page documents every method, the SQL each one builds, the error it can throw, and the exact reason the tenant predicate cannot be removed.
The whole class is about 230 lines and has no dependencies beyond the Database connection it wraps. That smallness is intentional: the isolation guarantee rests on one narrow, readable path, and a path you can read top to bottom is a path you can reason about. See Design Decisions for why this is a hand-written repository and not an ORM.
import { Repository } from "@/db";
import { Database } from "@/db/client";
const db = new Database(":memory:");
db.migrate();
const repo = new Repository(db);In the application you never do this by hand. db() in src/db/index.ts returns a process-wide Repository bound to the singleton connection. Tests construct their own via freshRepo() so each gets an isolated in-memory database.
The class splits its surface in two, and the split is enforced, not conventional.
| Family | Methods | First argument | Refuses |
|---|---|---|---|
| Scoped |
insertScoped, selectScoped, selectOneScoped, updateScoped
|
organisationId |
non-tenant tables |
| Global |
insertGlobal, selectGlobal, selectOneGlobal, updateGlobal, deleteGlobal
|
table |
tenant tables |
The guard is assertScoped for the scoped family, and an inline check for the global family. Both throw TenantScopeError when a table is used through the wrong family:
private assertScoped(table: string): void {
if (!TENANT_SCOPED_TABLES.has(table)) {
throw new TenantScopeError(
`table "${table}" is not tenant-scoped; use the global helpers instead`,
);
}
}Notice there is no method that takes a table and decides the family for you. The caller has to pick a family, and picking the wrong one throws rather than silently doing the wrong thing.
Stamps the tenant id onto the row, then inserts. The stamp happens after the spread, so a caller-supplied organisationId in the payload is overwritten, never honoured:
const scoped = { ...(row as Row), organisationId };The row always lands under the scope passed as the first argument. This is the property proved by the "a smuggled organisationId in an insert payload is overwritten" case in tests/tenant-isolation.test.ts.
Reads rows for one tenant. The tenant predicate is added first and any organisationId key in where is skipped, so it cannot be overridden:
const conditions = ["organisationId = @organisationId"];
for (const [key, value] of Object.entries(where)) {
if (key === "organisationId") continue; // never overridable
conditions.push(`"${key}" = @${key}`);
}The statement it builds for selectScoped(org, "memberships", { userId }):
SELECT * FROM "memberships" WHERE organisationId = @organisationId AND "userId" = @userIdselectScoped(...)[0] ?? null. A convenience for the common single-row lookup.
Updates rows for one tenant and returns the number changed. The tenant predicate is forced into the WHERE clause exactly as in selectScoped. The SET keys are prefixed set_ and the WHERE keys where_ so a column can appear in both without a parameter collision:
UPDATE "memberships" SET "role" = @set_role
WHERE organisationId = @organisationId AND "userId" = @where_userIdThe return value is load-bearing. A cross-tenant update returns 0, and callers such as MembersService.setRole treat 0 as "not found in this tenant" and refuse:
const changed = this.repo.updateScoped(ctx.organisationId, "memberships", { role }, { userId });
if (changed === 0) throw new Error("membership not found in this tenant");There is deliberately no deleteScoped. Tenant data is mutated, not destroyed, through updateScoped (for example a soft status change). If you need a hard delete on a tenant table, add it to the scoped family with the same predicate injection rather than reaching for a global path.
These operate on users, organisations and sessions. They refuse tenant tables, so you cannot accidentally read tenant data without a scope.
-
insertGlobal(table, row): plain parameterised insert; throws if
tableis tenant-scoped. -
selectGlobal(table, where = {}) and selectOneGlobal(table, where = {}): read with an optional
WHEREbuilt from the keys ofwhere; with nowherethey select all rows. -
updateGlobal(table, patch, where): like
updateScopedminus the tenant predicate, with the sameset_/where_prefixing. -
deleteGlobal(table, where): the only delete in the repository, used for sessions (logout, expiry). It refuses tenant tables with a message that points you back to
updateScoped.
Every value passes through toBind before it reaches a prepared statement, so nothing caller-supplied is ever interpolated into SQL:
type BindValue = null | number | bigint | string | Uint8Array;toBind maps undefined/null to NULL, booleans to 0/1, passes through numbers, bigints, strings and Uint8Array, and stringifies anything else. Column and table names are not values; they come from your own schema and the method arguments, never from request bodies, and they are quoted. The result is that there is no SQL injection surface in the repository.
| Error | Thrown when | Class location |
|---|---|---|
TenantScopeError |
a scoped method is used on a global table, or a global method on a tenant table | src/db/repository.ts |
TenantScopeError is exported from @/db for instanceof checks and is what tests/tenant-isolation.test.ts asserts on the "refuses scoped operations on non-tenant tables" case.
A signup writes a membership through the scoped path. Trace it:
this.repo.insertScoped(orgId, "memberships", {
id: membership.id,
userId: membership.userId,
role: membership.role,
createdAt: membership.createdAt,
});-
assertScoped("memberships")passes because the table is inTENANT_SCOPED_TABLES. -
scoped = { id, userId, role, createdAt, organisationId: orgId }. The tenant id is appended last. - Columns and
@nameplaceholders are derived fromscoped. -
toBind(scoped)normalises the values. - The prepared
INSERTruns once. The row is now readable only underorgId.
The method shapes are the seam. To move to Postgres you keep the signatures and the predicate-injection logic and replace the statement execution with pool calls. The SQL the repository builds is already standard parameterised SQL; the named-parameter style (@name) maps to your driver's parameter style. See Deployment for the full procedure and the row-level-security backstop.
SarmaLinux . sarmalinux.com . shipyard on GitHub
Start here
Subsystems
Reference
Operating it
Working on it
Background
SarmaLinux . sarmalinux.com