Recipes
Tenancy, audit stamping and optimistic locking, each one extension declaration you copy into your code
Three common needs fit in one declaration each, with no handler code:
tenancy, audit stamping and optimistic locking. They are recipes, not
packages: copy the ones you need into your code and change them as you like.
They use only what viborm and viborm/validation export, rows and data
(see Create an extension),
and each takes the names of the models it applies to.
import { defineExtension } from "viborm";
import { v } from "viborm/validation";
/**
* The same entry for each model named. `Object.fromEntries` forgets the
* names, so the cast gives them back: the types then know which models a
* recipe writes.
*/
export const perModel = <Models extends readonly string[], T>(
models: Models,
build: () => T
) =>
Object.fromEntries(models.map((model) => [model, build()])) as {
readonly [Model in Models[number]]: T;
};
A field these recipes write may be required in your schema: a create leaves it out and the recipe writes it, in your editor as when the call runs. Each recipe remembers the model names you pass it, so the types know which models it writes.
const post = s.model({
id: s.string().id().ulid(),
title: s.string(),
tenantId: s.string(), // written by tenancy
createdBy: s.string().nullable(), // written by audit
updatedBy: s.string().nullable(), // written by audit
version: s.int().default(0), // moved on by the lock
comments: s.toMany(() => comment),
});
Tenancy
Each call says which tenant it works for. It sees only that tenant’s rows,
through every relation, and every row it creates belongs to that tenant,
unless the call writes tenantId or the tenant relation itself (see the last
point below).
export const tenancy = <const Models extends readonly string[]>(
models: Models
) =>
defineExtension({
name: "tenancy",
controls: { tenant: { schema: v.string(), required: true } },
rows: {
control: "scope", // still one mode control per rows member
default: "tenant",
models: perModel(models, () => ({
tenant: {
root: { tenantId: { control: "tenant" } },
related: { tenantId: { control: "tenant" } },
},
all: {}, // an operator's view: the control is admitted, the filter is off
})),
},
data: {
models: perModel(models, () => ({
create: { tenantId: { control: "tenant" } },
})),
},
});
const db = base.$extends(tenancy(["post", "comment"]));
await db.post.findMany({ tenant: "acme" }); // only acme's posts, comments included
await db.post.create({ data: { title }, tenant: "acme" }); // tenantId written by the extension
await db.post.findMany({ tenant: "acme", scope: "all" }); // every tenant, for an operator
await db.post.findMany(); // ValidationError: control "tenant" is required
- Take the tenant from your server’s session, never from the request. The
extension keeps a call inside the tenant it names; it does not check that
the caller may name it. The same goes for
scope: "all": any code that can pass arguments can ask for every tenant. tenantis required and says noon, so every operation of the models you name asks for it. A model you do not name acceptstenantbut does not ask for it. Through that model’s relations, a call that passestenantreaches only that tenant’s rows of the models you name, and a call that does not reaches none of them: a required control left out never means “every tenant”.- A row of another tenant is hidden, so updating or deleting it is a
NotFoundError, and reaching it through a relation (connect, a nestedupdate,deleteorset) is aNestedWriteError, as for any row the extension hides. - Reads are kept to the call’s tenant; writes are not enforced. The
extension fills
tenantIdonly when the caller left it out: a create that writestenantId, or connects a tenant through its relation, by hand writes into that tenant, and so does an update that changes it (the recipe writestenantIdon create only). The call’s tenant then no longer reads that row. If callers must not choose a tenant, do not let them passtenantIdor the tenant relation to your write calls.
Audit stamping
Every create records who created the row, and every update who last changed it.
export const audit = <const Models extends readonly string[]>(models: Models) =>
defineExtension({
name: "audit",
controls: { actor: { schema: v.string(), required: true, on: "writes" } },
data: {
models: perModel(models, () => ({
create: { createdBy: { control: "actor" } },
update: { updatedBy: { control: "actor" } },
})),
},
});
const db = base.$extends(tenancy(["post", "comment"])).$extends(audit(["post", "comment"]));
await db.post.create({ data: { title }, tenant: "acme", actor: session.userId }); // tenantId, createdBy
await db.post.update({ where: { id }, data: { title }, tenant: "acme", actor: session.userId }); // updatedBy
- Every create and update writes the fields: root and nested, one row or
many, either arm of an
upsert. A soft delete is an update, so it writesupdatedBytoo; who deleted the row stays with the soft delete’s owndeletedBy. actoris required on every write of the models you name, deletes included, even where nothing is written: a delete that stays physical writes nothing. A write of another model acceptsactorbut does not ask for it.
Optimistic locking
An update or a delete says which version of the row it read. When the row has moved on since, the call finds nothing and changes nothing. Every update moves the version on.
export const optimisticLock = <const Models extends readonly string[]>(
models: Models
) =>
defineExtension({
name: "optimisticLock",
controls: {
expectedVersion: { schema: v.number(), on: ["update", "delete"] },
},
rows: {
control: "versionCheck",
default: "checked",
models: perModel(models, () => ({
checked: { root: { version: { control: "expectedVersion" } } },
unchecked: {},
})),
},
data: {
models: perModel(models, () => ({
update: { version: { increment: 1 } },
})),
},
});
const db = base.$extends(optimisticLock(["post"]));
await db.post.update({ where: { id }, data, expectedVersion: 3 }); // NotFoundError when the row moved on
- A call without
expectedVersionchecks nothing, and the version still moves on. Mark the controlrequired: truewhen every update and delete of the models you name must check. updateMany,upsertanddeleteManydo not takeexpectedVersion: they check nothing, andupdateManyandupsertmove the version on.- A create or an update that writes
versionitself keeps that value: on an update it replaces the increment. TheexpectedVersioncheck still reads the version the row had.
What every recipe shares
- The extension fills a field only when the caller left it out. A call
that writes one of the fields keeps its own value there, at the root and
in every nested create or update, and the extension writes nothing for
that field. A field written as
undefinedcounts as left out. Writing it through a relation counts as writing it: whentenantIdis the foreign key of atenantrelation, a call that passestenant: { connect: ... }(orcreate,connectOrCreate, or a nestedupdateof the tenant) decides the key itself. When two recipes write the same field, the one applied later wins, and the caller wins over both. - Your editor knows what they write. Each recipe keeps the model names
you pass it, so on the models it names a field it writes on create may be
left out of a create, at the top level and in a create nested through a
relation; written by hand, it keeps its own type. A model it does not name
keeps its fields as the schema has them, and a misspelt model name is an
editor error where the recipe is applied. Two cases the types do not see.
A list built at runtime (a plain
string[]) tells them no names, so a required field cannot be left out in your editor: pass the names as a written list. A create nested through a relation with variants is not rebuilt: there the field is asked for as the schema has it, although the call accepts it left out. And the types find a nested create’s model by its fields, not its name: a model the recipe does not name, but whose fields and relations are the same as a named model’s, is treated like it in a nested create. There the editor lets you leave the field out, and the call is refused as missing when it runs. - Each tenant is prepared once. A client keeps up to 256 prepared modes
and values; past that, the oldest is prepared again when it comes back,
which is slower but gives the same answer. Tenancy with both modes in use
keeps 128 tenants. Values that only
datauses, such asactor, take no room. A value arowsfilter names does take room: on a client with the optimistic lock, each update or delete that passes a newexpectedVersiontakes an entry, so steady writes push tenants out. To keep the tenants, apply the lock on a client of its own, such asconst locked = db.$extends(optimisticLock([...])), and send the locked writes there:dbkeeps its own 256 entries, and the lock’s versions never take them. - Cached reads are keyed on the value, so two tenants never share a cache entry.
- Combine them freely. Apply them in any order, before any extension
that adds model-mapped
queryhandlers orclient/modelmethods. When two extensions write the same field, the one applied later wins.
Not covered
- Raw SQL (
$queryRaw,$executeRawand the unsafe forms) and statement transforms are neither filtered nor stamped. - A filter compares a field with a value the call passed, nothing more: no value computed from another, no filter through a relation, no control of another extension.
- None of this is authorization. A filter keeps a call inside the rows it names; deciding what a caller may name stays in your server.