QUESTPIE
Schema

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.

View markdown

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.

src/questpie/server/globals/site-settings.ts
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 production

Reading 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",
})
RuleRuns beforeWhat the rule sees
reada readno row loaded
updatea writedata is the current row, null on the first write
transitiona stage changethe current row, falls back to update when omitted
introspectthe admin loading a schemavisible when read or update already allows it
fieldseach 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

OptionTypeDefaultWhat it does
timestampsbooleantrueAdds createdAt and updatedAt
schemastringpublicPuts every table in a Postgres schema
versioningboolean | { enabled?, maxVersions?, workflow? }falseVersions and stages
optimisticConcurrencytrueoffAdds revision, requires expectedRevision on every write
scoped(ctx) => string | null | undefinednoneOne row per tenant
realtime{ accessCacheKey }noneSharing policy for live subscriptions

There is no softDelete option. A singleton is never deleted.

What lands in the database

TableWhen
<name>always
<name>_i18nany field is .localized()
<name>_versionsversioning is on
<name>_i18n_versionsboth 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

On this page