Globals
A global is one row instead of a table. It takes the same fields, hooks and access rules as a collection, and generates get() and update() in place of five CRUD methods.
Site settings, a homepage, a footer. You do not want a list of those, you want one record that is always there. This page declares one, puts it in the admin, and reads it back.
Declaring one
A global is a file under globals/, and the file is the registration. The
string you pass to global() is the table name and the key you reach it by, so
global("site_settings") gives you app.globals.site_settings. A hyphen turns
into camelCase, global("site-settings") would give app.globals.siteSettings.
import { global } from "#questpie/factories";
export const siteSettings = global("site_settings")
.fields(({ f }) => ({
siteName: f.text().label("Site name").required().default("My Site"),
tagline: f.text().label("Tagline").localized(),
logo: f.upload({ to: "assets" }).label("Logo"),
}))
.access({ read: true })
.admin(({ c }) => ({
label: "Site settings",
icon: c.icon("ph:gear"),
}))
.form(({ v, f }) =>
v.globalForm({ fields: [f.siteName, f.tagline, f.logo] }),
);.fields(), .access(), .hooks() and .options() come from the core and may
appear in any order. .admin() and .form() are added to the builder by
@questpie/admin, so they exist only where that module is enabled.
Import the generated factory, not the package
#questpie/factories injects your module field types into f. A plain
global() imported from questpie sees the builtin types only.
Building it
questpie generate # regenerate the typed app surface
questpie push # create the table in dev, migrate:generate for productionReading it
import { app } from "#questpie";
const settings = await app.globals.site_settings.get({ with: { logo: true } });
console.log(settings?.siteName); // "My Site"The first read creates the row. get() takes a Postgres advisory lock, inserts
an empty row and returns it, so two concurrent boots cannot end up with two
singletons. The empty insert picks up the column defaults, which is what
.default() writes.
Give every required field a default
.required() makes the column NOT NULL. The first read inserts an empty
row, and a NOT NULL column without a default rejects it.
get(options, context) accepts with, columns, locale, localeFallback
and stage. It returns null only when you ask for a workflow stage that has
no snapshot yet.
Writing it
await app.globals.site_settings.update({ siteName: "QUESTPIE" });update() patches the row and returns it, never null. beforeUpdate and
beforeChange run before the write, then afterUpdate and afterChange, in
that order. Nothing validates the patch first. The schema built from your fields
is what @questpie/openapi publishes as the request body, not a runtime check.
The patch comes first, and there is no id
The server signature is update(data, context, options). A collection picks
one record out of many with updateById({ id, data }, context). A global has
nothing to pick.
Localized fields are written one locale at a time:
await app.globals.site_settings.update(
{ tagline: "Sharp cuts, every time" },
{ accessMode: "system", locale: "en" },
);Who may read and write it
Each rule is a boolean or a function returning one. A collection rule can also
return a row filter. A global rule cannot, because there is only ever one row.
.access({
read: true,
update: ({ session }) =>
(session?.user as { role?: string } | undefined)?.role === "admin",
})| Rule | Runs before | What the rule sees |
|---|---|---|
read | a read | no row loaded |
update | a write | data is the current row, null on the first write |
transition | a stage change | the current row, falls back to update when omitted |
introspect | the admin loading a schema | visible when read or update already allows it |
fields | each field | { read?, update? } keyed by field name |
An omitted rule is not public
It requires a session. Set read: true to serve a global to anonymous
visitors. A call with no request around it runs as system and skips the
rules. Inside a route handler it inherits user and enforces them.
Running code around a write
.hooks({
afterChange: async ({ data, kv }) => {
await kv.set("site-settings-cache", data);
},
})The eight hooks are beforeRead, afterRead, beforeUpdate, afterUpdate,
beforeChange, afterChange, beforeTransition and afterTransition. There
is no create or delete hook. A global is only ever updated.
A second hooks call replaces the first
So does a second .access() or .options(). Only a collection's .hooks()
merges repeated calls into arrays. Everything else overwrites on both.
.fields() is the exception, it adds to what is there and overrides by key.
Options
| Option | Type | Default | What it does |
|---|---|---|---|
timestamps | boolean | true | Adds createdAt and updatedAt |
schema | string | public | Puts every table in a Postgres schema |
versioning | boolean | { enabled?, maxVersions?, workflow? } | false | Versions and stages |
optimisticConcurrency | true | off | Adds revision, requires expectedRevision on every write |
scoped | (ctx) => string | null | undefined | none | One row per tenant |
realtime | { accessCacheKey } | none | Sharing policy for live subscriptions |
There is no softDelete option. A singleton is never deleted.
What lands in the database
| Table | When |
|---|---|
<name> | always |
<name>_i18n | any field is .localized() |
<name>_versions | versioning is on |
<name>_i18n_versions | both of the above |
The main table holds id, your unlocalized fields, and createdAt /
updatedAt unless you turn timestamps off. Infer the row type from the export
with typeof siteSettings.$infer.select. It carries no _title. That computed
field comes from a collection's .title(), and a global has no list to label.
From the browser
client.globals.site_settings mirrors the server without the context argument.
The session rides in the cookie, so client calls always run in user mode.
const settings = await client.globals.site_settings.get({
with: { logo: true },
});
await client.globals.site_settings.update({ siteName: "QUESTPIE" });It also carries schema(), meta(), findVersions(), revertToVersion() and
transitionStage(), on /api/globals/:name and its /schema, /meta,
/versions, /revert and /transition children. live() and liveIter()
arrive with a realtime adapter, over the shared /api/realtime stream instead.
Next
- Versions and stages keeps a history and adds draft to published.
- One row per tenant turns the singleton into one row per city, property or customer.
- Fields is the catalog
.fields()draws from. - Access control covers the rule model globals share with collections.