One row per tenant
A scoped global keeps one row per city, property or customer behind a single definition, and picks the right one from the request context rather than from an argument you pass.
You run twelve city portals off one deployment. Each needs its own site name, its own logo, its own alert banner, and none of it belongs in a collection with a list view. One definition, twelve rows.
The resolver
Give .options() a scoped function. It receives the request context and
returns the scope id.
import { global } from "#questpie/factories";
export const siteSettings = global("site_settings")
.fields(({ f }) => ({
siteName: f.text().required().default("City Council"),
alertMessage: f.textarea(),
}))
.access({ read: true })
.options({
scoped: (ctx) => (ctx as typeof ctx & { cityId: string | null }).cityId,
});ctx.cityId is a context extension. You return it from context in the app
config, and it is on every request thereafter:
import { appConfig } from "questpie/app";
export default appConfig({
context: async ({ request }) => ({
cityId: request.headers.get("x-selected-city") || null,
}),
});Whatever identifies the tenant, a header, a subdomain, a session claim, is the same thing that selects the row.
Reading and writing it
Nothing about the call changes.
const ctx = await app.createContext({ request });
const settings = await app.globals.site_settings.get({}, ctx);
console.log(settings?.siteName); // whichever city the request resolved tocreateContext() merges what your context resolver returned onto the context
it hands out, which is how ctx.cityId reaches the scoped function. It only
runs that resolver when you pass a request, so a context built without one
resolves to no scope. The scope is never an argument you pass. A request that
resolves to Bristol reads and writes the Bristol row and cannot reach the Leeds
one, because every query is filtered on scope_id before it runs.
Passing a request also puts the call in user mode, which is why the
definition above sets read: true. Without a rule the global asks for a
session.
A null scope selects the shared row
When the resolver returns null or undefined, the global resolves the row
whose scope_id is NULL. Use it for a default that unrecognised tenants
fall back to.
What changes in the database
The main table gains a nullable scope_id column and a unique index named
<name>_scope_idx on it, which is what keeps one row per named scope. Postgres
counts nulls as distinct, so the shared row is held to one by the advisory lock
instead. Turn versioning on and its table carries scope_id too, so history
stays partitioned by tenant.
Each scope's row is created on its first read, the same way an unscoped global's is: an advisory lock, an empty insert, the column defaults. Twelve cities means twelve rows, appearing as each one is first visited.
Related
- Globals covers the builder these options sit on.
- Versions and stages works per scope.
- Runnable example:
examples/city-portal/src/questpie/server/globals/site-settings.ts.
Versions and stages
Turning on versioning snapshots every write of a global into a history table, and nesting a workflow under it adds draft and published stages you move between.
Text field
`f.text()` is the single-line string. The call picks the varchar width, the width becomes the Zod cap, and the field filters with the full string operator set.