# Globals (/docs/schema/globals)

---
title: Globals
description: 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.
kind: guide
package: questpie
---

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`.

```ts title="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.

<Callout type="info" title="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.
</Callout>

## Building it

```bash
questpie generate   # regenerate the typed app surface
questpie push       # create the table in dev, migrate:generate for production
```

## Reading it

```ts
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.

<Callout type="warn" title="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.
</Callout>

`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

```ts
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.

<Callout type="warn" title="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.
</Callout>

Localized fields are written one locale at a time:

```ts
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.

```ts
.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             |

<Callout type="warn" title="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.
</Callout>

## Running code around a write

```ts
.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.

<Callout type="warn" title="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.
</Callout>

## 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](/docs/schema/globals/versions)        |
| `optimisticConcurrency` | `true`                                             | off      | Adds `revision`, requires `expectedRevision` on every write |
| `scoped`                | `(ctx) => string \| null \| undefined`             | none     | [One row per tenant](/docs/schema/globals/scoped)           |
| `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.

```ts
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](/docs/schema/globals/versions)** keeps a history and
  adds draft to published.
- **[One row per tenant](/docs/schema/globals/scoped)** turns the singleton into
  one row per city, property or customer.
- **[Fields](/docs/schema/fields)** is the catalog `.fields()` draws from.
- **[Access control](/docs/schema/access-control)** covers the rule model
  globals share with collections.
