A table in the per-tenant schema — naming it, picking its tier, writing its policies, and the query and action layer over it.
Tenant isolation is connection-layer: each tenant is its own database with one public schema, and no table carries a tenant id. Authorization is Postgres row-level security, not the app gate. Every exposed table has it enabled, with policies whose role list names who may read and who may write.
not_specified entry.status_code for state, a JSON metadata column for flags. A boolean is only for a fact that never gains a third state._items or _details._at is an instant, _time a clock time.| Tier | Rule | Examples |
|---|---|---|
| Module anchor | A plural noun, no prefix. It is the namespace. | products, orders, persons |
| Every other table | Prefixed with the module or a bucket it owns. | product_options, order_lines, leave_requests |
| Pure link | <a>_<b>_links | product_collection_links |
| Association entity | A subordinate noun, never _links. | organization_persons |
| Master and detail | Named for what the row is. | order_payments, file_versions |
A prefix names what a row always has, never an optional foreign key. A noun a tenant would recognise on a sidebar stands bare where one module owns the concept outright. Views are <table>_view, indexes idx_<table>_<column>, constraints keep Postgres's default shape.
The Drizzle schema is the source; the SQL under the migrations folder is generated output. Each schema file is listed explicitly in the Drizzle config, because the folder barrel would make the generator see every table twice, so a new file is added to that list and to the barrel separately.
Stateful DDL, tables, columns, constraints, indexes, policies, is the numbered chain, applied once per database and recorded. Stateless DDL, functions, views, triggers, grants, RPCs, is replayed on every apply and is never a migration; see schema migrations. Generate the next chain entry from the diff, commit the snapshot with it, and never regenerate the baseline. The chain is additive; a deliberate drop declares itself.
After touching SQL or a seed, regenerate the bundled SQL module, because a bundler ships modules, not files on disk.
export const organizations = pgTable(
"organizations",
{
id: uuid()
.default(sql`uuidv7()`)
.primaryKey()
.notNull(),
parent_id: uuid("parent_id"),
code: text().default("").notNull(),
name: text().notNull(),
type_code: text("type_code").default("not_specified").notNull(),
type_lookup_type: text("type_lookup_type")
.generatedAlwaysAs(sql`'organization_types'::text`)
.notNull(),
status_code: text("status_code").default("not_specified").notNull(),
status_lookup_type: text("status_lookup_type")
.generatedAlwaysAs(sql`'organization_statuses'::text`)
.notNull(),
metadata: jsonb(),
created_by: uuid("created_by"),
created_at: timestamp("created_at", { withTimezone: true, mode: "string" })
.defaultNow()
.notNull(),
updated_at: timestamp("updated_at", { withTimezone: true, mode: "string" })
.defaultNow()
.notNull(),
},
(table) => [
/* indexes, foreignKey(), unique(), pgPolicy(), check() */
],
);Assign the tier by sensitivity: products public, organizations protected, persons private. Transactions are private. Widen per requirement, never speculatively.
Two policies per table, a read and a write. Copy the pair from a table already on the tier you picked:
pgPolicy("organizations_read", {
for: "select",
to: ["authenticated", "reader"],
using: sql`true`,
}),
pgPolicy("organizations_write", {
for: "all",
to: ["authenticated"],
using: sql`(SELECT is_admin()) OR created_by = (SELECT current_person_id())`,
withCheck: sql`(SELECT is_admin()) OR created_by = (SELECT current_person_id())`,
}),Pick the path by transport and privilege, as data access lays out: the cached read-only role for public and protected server reads, the Data API as the signed-in user for private reads and every session write, a privileged function over the sessionless writer for server-to-server writes.
A cached read:
export async function getProductById(tenantSlug: string, id: string) {
"use cache";
cacheTag("products");
cacheLife("max");
const db = await getReaderDb(tenantSlug);
return (await getProduct(db, id)) ?? null;
}Keep the slug internal: a private cached function taking the slug, and a slug-less wrapper that resolves the tenant and calls it. Never translate inside the scope, and never cache a tenant miss.
A write:
"use server";
export async function createOrganizationAction(data: CreateOrganizationInput) {
await protect();
const api = await getDataApi();
let org: Organization;
try {
org = await createOrganization(api, data);
} catch (error) {
return err(error);
}
updateTag("organizations");
refresh();
return ok(org);
}Verify the session first, return a result rather than throwing, then expire the tag and refresh. A multi-statement write cannot go over the serverless driver; wrap it in a function and expose it as an RPC.
A list* returns a collection, a get* one or null. A bounded reference table is fetched whole; an unbounded one is read by keyset cursor, with a composite index per feed, the id as tie-breaker, and only non-null columns sortable.
not_specified.(created_at, id) index if it feeds an infinite grid.| Symptom | Cause |
|---|---|
| The generator errors on duplicate tables | The folder barrel was listed instead of the files. |
| Policies missing from the generated SQL | Role emission is off in the config, or the role did not exist at apply time. |
| A signed-out client still gets 403 after a new grant | The Data API cached grants at creation and needs reprovisioning. Policy changes are live; grants are not. |
| Every infinite feed stops after one block | The Data API's max rows was capped at the block size. Leave it unset. |
| A SQL change has no effect at runtime | The bundled SQL module was not regenerated. |
| The serverless driver rejects the write | A multi-statement transaction over HTTP. Move it into a function. |
| A policy helper makes a query a hundred times slower | It is called per row. Wrap it in a sub-select. |