Tenancy

Tenant keys on every table that does not opt out.

tenancy is columnTenancy(), compositeTenancy(), or rlsTenancy() from okmodel/tenancy. Omitted, no table gains a key. Importing the module is what loads the predicate.

Each key is a guarded uuid. Insert fills it from for(). Reads and writes filter on it. Input cannot set it. A unique and the primary key include every key. An index must start with the key. global("reason") and via() are the table marks. Both are on tenancy.

omitted

No tenant column, and for() is not on the client. A table that still sets tenancy is refused.

omitted.ts
import { schema, table, t } from "okmodel/pg";

const notes = table("notes", {
  id: t.identity(),
  title: t.text(),
});

export default schema({
  tables: [notes],
});

OKM1060

Table notes sets tenancy, and the schema does not. A value that is not columnTenancy(), compositeTenancy(), or rlsTenancy() is schema() tenancy must be columnTenancy() from okmodel/tenancy.

column

columnTenancy({ key, type: "uuid" }) adds one key. type is "uuid".

column.ts
import { columnTenancy } from "okmodel/tenancy";
import { schema, table, t } from "okmodel/pg";

const notes = table("notes", {
  id: t.identity(),
  title: t.text(),
});

export default schema({
  tables: [notes],
  tenancy: columnTenancy({ key: "tenantId", type: "uuid" }), 
});

for({ tenantId }) opens a client for one tenant. The value is a uuid. unscoped("reason") drops the predicate. Insert still needs for(). The root client keeps a global table and hides a tenant table.

for.ts
import { connect } from "okmodel/pg/postgresjs";
import { columnTenancy, global } from "okmodel/tenancy";
import { schema, table, t } from "okmodel/pg";

const plans = table(
  "plans",
  { id: t.identity(), code: t.text() },
  { tenancy: global("plans are shared") }, 
);

const notes = table("notes", {
  id: t.identity(),
  title: t.text(),
});

const app = schema({
  tables: [plans, notes],
  tenancy: columnTenancy({ key: "tenantId", type: "uuid" }),
});

export async function add(url: string, tenantId: string, title: string) {
  await using db = connect(url, { schema: app });
  await db.plans.find({ limit: 1 });
  return db.for({ tenantId }).notes.insert({ title }); 
}

OKM1060

columnTenancy() needs { key, type }. Another type is columnTenancy() type text must be uuid. An empty key is columnTenancy() key must be a field name. A table mark that is neither global("reason") nor via() is Table notes tenancy must be global("reason") or via(). An empty reason is global() needs a reason. A global unique with no reason is Unique notes.email sets global and needs a reason. set null on a tenant foreign key is Foreign key notes.authorId includes tenantId, so set null cannot clear it. Accepted actions: cascade, restrict, no action.

OKM1012

Table notes already declares tenantId, which tenancy adds. Rename the field or drop it from the table.

OKM1190

Insert that names the key is Field notes.tenantId is the tenant key. The scope sets it. Input cannot.

OKM1704

An update is Field notes.tenantId is the tenant key. An update cannot change it. A filter is Field notes.tenantId is the tenant key. A filter cannot set it.

OKM1701

No scope is Table notes is tenant-scoped. Call for({ tenantId }) or unscoped("reason"). Insert without one is insert on notes has no tenant. Call for({ tenantId }) so the scope can set it. for() needs { tenantId }, and the value must be a uuid: for() needs { tenantId } as a uuid. Another field is for() accepts tenantId. organizationId is not the tenant key. An empty reason is unscoped() needs a reason.

OKM1705

A global table that references a tenant table is Table plans.code is global and references tenant table notes. Drop the reference, or make plans a tenant table.

OKM1706

An index that does not lead with the key is Index on notes starts with title. Put tenantId first. With casing "snake", the name in that sentence is tenant_id.

composite

compositeTenancy({ key, type: "uuid" }) adds every name, in that order. for() takes every key. One name is columnTenancy().

composite.ts
import { connect } from "okmodel/pg/postgresjs";
import { compositeTenancy } from "okmodel/tenancy";
import { schema, table, t } from "okmodel/pg";

const documents = table("documents", {
  id: t.identity(),
  title: t.text(),
});

const app = schema({
  tables: [documents],
  tenancy: compositeTenancy({
    key: ["organizationId", "workspaceId"], 
    type: "uuid",
  }),
});

export async function add(
  url: string,
  organizationId: string,
  workspaceId: string,
  title: string,
) {
  await using db = connect(url, { schema: app });
  return db.for({ organizationId, workspaceId }).documents.insert({ title }); 
}

OKM1060

compositeTenancy() needs { key, type }. Another type is compositeTenancy() type text must be uuid. A key that is not a list is compositeTenancy() key must be a list of field names, such as ["organizationId", "workspaceId"]. An empty name is compositeTenancy() key must be a field name. A repeated name is compositeTenancy() key repeats organizationId.

OKM1701

for() without both keys is for() needs { organizationId, workspaceId } as a uuid. Another field is for() accepts organizationId, workspaceId. extra is not the tenant key.

row level

rlsTenancy({ key, type: "uuid" }) is one key. The column predicate stays. Policies are the backstop: notes_tenant and notes_unscoped_select on a table named notes. A view that exposes the key is security_invoker. via() and a key list are refused. unscoped() can read and cannot write.

rls.ts
import { rlsTenancy } from "okmodel/tenancy";
import { schema, table, t } from "okmodel/pg";

const notes = table("notes", {
  id: t.identity(),
  title: t.text(),
});

export default schema({
  tables: [notes],
  tenancy: rlsTenancy({ key: "tenantId", type: "uuid" }), 
});

A healthy role check is cached for that endpoint until its generation changes. A failed check is not cached. The message names the endpoint.

OKM1060

rlsTenancy() needs { key, type }. A key list is rlsTenancy() takes one key. Combining it with compositeTenancy or via() is not supported yet. A path table is Table tasks uses via() with rlsTenancy(). Combining them is not supported yet.

OKM1707

The owner is The connected role owns the tenant tables, so row-level security would not apply. A superuser is The connected role is a superuser, so row-level security would not apply. BYPASSRLS is The connected role has BYPASSRLS, so row-level security would not apply. Each sentence then names the endpoint: Endpoint primary. A role pg_roles does not list is The connected role is not visible in pg_roles. Endpoint primary.

OKM1708

A policy that does not match is Table notes does not force row-level security. Endpoint primary. The words before the endpoint are does not force row-level security., is missing forced row-level security., policy notes_tenant does not match the tenant predicate., policy notes_unscoped_select does not match the unscoped predicate., or has a row-level security policy okm did not emit.

OKM1701

unscoped() cannot write under row-level security.

path

via("project.organization") follows to-one relations, at most three. The last table stores the key. This table does not. A read or a write adds exists along that path.

path.ts
import { columnTenancy, via } from "okmodel/tenancy";
import { one, schema, table, t } from "okmodel/pg";

const organizations = table("organizations", {
  id: t.identity(),
  name: t.text(),
});

const projects = table(
  "projects",
  {
    id: t.identity(),
    organizationId: t.bigint().references("organizations"),
    name: t.text(),
  },
  { relations: { organization: one("organizations") } },
);

const tasks = table(
  "tasks",
  {
    id: t.identity(),
    projectId: t.bigint().references("projects"),
    title: t.text(),
  },
  {
    relations: { project: one("projects") },
    tenancy: via("project.organization"), 
  },
);

export default schema({
  tables: [organizations, projects, tasks],
  tenancy: columnTenancy({ key: "tenantId", type: "uuid" }),
});

OKM1060

An empty path is via() needs a relation path.

OKM1705

More than three steps is Table tasks tenancy via "a.b.c.d" is longer than 3 hops. A missing to-one relation is Table tasks tenancy via "project.organization" has no to-one relation project. A path that ends on a global table is Table tasks tenancy via "project.organization" ends at plans, which is not a tenant table.