Globals
Hay contenido del que solo existe un ejemplar: la cabecera del sitio, el pie, los metadatos de
SEO. Modelarlo como colección obliga a fingir que hay muchos —un listado con una fila, una pantalla
de alta que nadie debe usar, un findOne() con los dedos cruzados—. Un global es ese documento
único, con nombre propio y una sola pantalla.
Qué es un global
Sección titulada «Qué es un global»| Colección | Global | Ajustes | |
|---|---|---|---|
| Cardinalidad | N documentos | Uno, siempre | Uno, con siete campos fijos |
| Campos | Los que declares | Los que declares | Fijos, los define el CMS |
| Operaciones | find, findByID, findOne, count, create, update, delete |
get, update |
get, update |
| Dónde vive | D1 | KV | KV |
| Cómo se direcciona | /api/cms/:slug/:id · /admin/collections/:slug |
/api/cms/globals/:slug · /admin/globals/:slug |
/api/cms/settings · /admin/settings |
¿Destino de un relationship? |
Sí | No | No |
| ¿Necesita migración? | Sí | No | No |
Un global vive en Cloudflare KV, con una clave por campo —global:<slug>:<campo>— más
global:<slug>:createdAt y global:<slug>:updatedAt. Es el mismo namespace de los ajustes, el que
declara bindings.kv, y es una clave por campo por la misma razón: dos guardados a la vez de campos
distintos no se pisan.
De ahí sale la consecuencia práctica: un global no crea ninguna tabla y no necesita migración.
Declarar uno, cambiarle un campo o quitarlo no genera SQL, y cms db:generate --check ni lo mira.
Ver Schema y migraciones.
Declararlo
Sección titulada «Declararlo»defineGlobal en tu cms.config.ts, y el global en el array globals:
import { defineConfig, defineGlobal } from '@kevolution-co/cms'
const site = defineGlobal({ slug: 'site', label: 'Sitio', admin: { description: 'Lo que el sitio dice de sí mismo en todas las páginas' }, access: { read: 'public' }, fields: [ { name: 'tagline', type: 'text', required: true, defaultValue: 'Un CMS para Astro sobre Cloudflare', }, { name: 'footer', type: 'textarea', admin: { description: 'El texto del pie' } }, { name: 'logo', type: 'upload', to: 'ssd', admin: { position: 'sidebar' } }, { name: 'nav', type: 'json', admin: { description: 'Enlaces extra del menú: [{ href, label }]' }, }, ],})
export default defineConfig({ collections: [posts], globals: [site],})| Opción | Por defecto | Qué hace |
|---|---|---|
slug |
— | Nombra la ruta REST, la URL del panel y el prefijo de sus claves en KV |
fields |
— | Los mismos nueve tipos que una colección, con las mismas opciones |
label |
Derivado del slug | La etiqueta del panel y el nombre de la interfaz generada |
admin.description |
— | Se pinta bajo el título de la pantalla |
admin.hidden |
false |
Lo quita de la barra lateral. Su URL sigue funcionando |
admin.group |
— | Lo archiva bajo ese epígrafe, junto a las colecciones que lo declaren |
access.read |
Exige sesión | Con 'public', el GET por HTTP se sirve sin sesión |
El label que no declares sale del slug, con la inicial de cada tramo en mayúscula y sin recortar
la s final: un global no es un plural, así que 'settings' da «Settings» y no «Setting». Un slug
de varios tramos se escribe con guion y su label sale con espacios: 'site-header' da «Site Header»,
y la interfaz generada, SiteHeader. No hay labels: { singular, plural }, ni useAsTitle, ni
defaultColumns: nada de eso significa nada donde no hay listado.
El slug es único entre colecciones y globals a la vez: los dos nombran una URL del panel y una
ruta, así que declarar la colección site y el global site es un ConfigError al arrancar. Y
globals está reservado como slug de colección, porque /api/cms/globals/:slug tiene la misma
forma que /api/cms/:coleccion/:id y la taparía sin decir nada. Al revés la ruta no se tapa, pero
el label que sale de globals es «Globals», y Globals es como se llama el mapa que el codegen
emite: un label que choque con él —o con la interfaz de otra colección u otro global— es un
ConfigError con código DUPLICATE_INTERFACE al arrancar, así que un global globals necesita un
label propio.
Desde una plantilla, igual que una colección y con el mismo Astro.locals.cms:
---export const prerender = false
const site = await Astro.locals.cms.globals.site.get()---
<p>{site.tagline}</p>prerender = false en toda página que lo lea: en build no hay bindings de Cloudflare y no hay nada
que leer.
get() acepta dos opciones, las mismas que una lectura de documento:
| Argumento | Por defecto | Qué hace |
|---|---|---|
depth |
1 |
Hasta qué nivel se resuelven relationship y upload. Entero de 0 a 10 |
select |
Todo | Los campos que quieres. id entra siempre |
await cms.globals.site.get({ depth: 0 }) // logo se queda como idawait cms.globals.site.get({ select: ['tagline'] }) // { id, tagline }Un select con un campo que el global no declara es QueryError, y salta sin llegar a tocar KV.
Las firmas exactas están en GlobalAPI<T>.
El tipo lo escribe cms db:generate en .cms/types.d.ts: una interfaz por global, nombrada como su
label, y un Globals que las reúne.
export interface Sitio { id: string createdAt: Date | null updatedAt: Date | null tagline: string footer: string | null logo: string | Medio | null nav: unknown | null}
export interface Globals { site: Sitio}id es el slug, no un UUID: solo hay un documento y ya sabes cuál es. Las dos fechas son
Date | null y no Date, que es la diferencia que cuenta con una colección — y la razón está en la
sección siguiente.
depth sigue leyendo D1
Sección titulada «depth sigue leyendo D1»Un global vive en KV, pero lo que guarda de un relationship o de un upload es el id, y ese id
apunta a una fila de D1. depth lo hidrata exactamente igual que en una colección, por lotes y con
el mismo techo de 10 niveles. Ver Relaciones: depth.
Antes del primer guardado
Sección titulada «Antes del primer guardado»Un global existe desde que lo declaras. Antes de que nadie lo guarde no hay ninguna clave en KV,
y get() no falla ni devuelve null: devuelve el documento por defecto.
await cms.globals.site.get()// {// id: 'site',// createdAt: null,// updatedAt: null,// tagline: 'Un CMS para Astro sobre Cloudflare', // su defaultValue// footer: null, // sin defaultValue// logo: null,// nav: null,// }Cada campo lleva su defaultValue —evaluado en cada lectura si es una función— o null si no
declara ninguno. Las dos fechas son null, y por eso el tipo generado dice Date | null: no
son las fechas de una fila que aún no existe, son la respuesta honesta a «esto todavía no se ha
guardado nunca».
get() no escribe jamás. Ni la primera vez, ni para «inicializar» el documento. Leer un global
en una plantilla que se pinta en cada petición no cuesta una sola escritura en KV.
Y get() nunca lanza NotFoundError, a diferencia de findByID. No hay fila que pueda faltar.
Guardarlo
Sección titulada «Guardarlo»await cms.globals.site.update({ tagline: 'Hola' })update() valida el documento completo, no solo lo que le mandas: funde lo que hay guardado
—o los defaultValue si nunca se guardó— con lo que llega, y pasa el resultado entero por la misma
validación que usa create.
Si el global declara hooks, corren aquí: beforeValidate ve el
parche tal como llegó, y beforeChange ve ya el documento completo y validado. Lo que un
beforeChange deje distinto de lo que había se guarda, así que es donde se derivan los campos
que nadie escribe a mano.
lo guardado (o los defaultValue) ⊕ lo que envías = lo que se validaLa consecuencia que sorprende: un campo required sin defaultValue que falte en el primer
guardado es un ValidationError con código REQUIRED, aunque tu patch no lo mencione. Es a
propósito: lo que quede guardado cumple siempre lo que el config exige.
Lo que se escribe sí es parcial, y depende de si es el primer guardado:
| Situación | Qué claves se escriben |
|---|---|
| Primer guardado | Todos los campos, con lo enviado o su defaultValue, más createdAt y updatedAt |
| Guardados siguientes | Solo los campos que vienen en la llamada, más updatedAt |
Un undefined explícito cuenta como ausente: no pisa lo guardado, así que
update({ tagline: undefined }) deja tagline como estaba. En el primer guardado su clave se
escribe igual, con el defaultValue, como la de cualquier campo que no venga en la llamada. Un
null explícito sí se guarda, y vuelve como null en vez de caer al defaultValue.
id, createdAt y updatedAt no se pueden escribir: llegan como READ_ONLY_FIELD. Un campo que
el global no declara, como UNKNOWN_FIELD.
Lo que KV no da
Sección titulada «Lo que KV no da»Tres cosas, y ninguna se disimula:
No hay escrituras condicionales. KV no tiene put condicional, así que update() no acepta
ifMatch y la ruta REST rechaza If-Match con un 412 que lo explica. Lo que una colección te
deja hacer —escribir solo si nadie tocó el documento desde que lo leíste— aquí no existe. Dos
pestañas guardando el mismo campo: gana la última.
No hay consistencia inmediata. Una escritura tarda hasta un minuto en verse en el resto del
mundo: KV propaga en unos 60 segundos, y encima el CMS cachea cada global otros 60 en el isolate
que lo leyó. Quien guarda sí ve su cambio al instante, porque update() sustituye la entrada
de la caché en su propio isolate.
No hay claves foráneas. Ya está arriba: un id que no existe se guarda igual.
Sin namespace de KV
Sección titulada «Sin namespace de KV»Si tu wrangler.jsonc no declara el namespace que espera bindings.kv, el sitio no se cae:
get()devuelve los valores por defecto y avisa una vez por isolate en la consola.update()lanza unConfigErrorcon códigoMISSING_KVque dice qué añadir y dónde.- La pantalla del panel avisa de que no se puede guardar y no pinta el formulario: ofrecer un guardado que va a fallar es peor que no ofrecerlo.
Por HTTP
Sección titulada «Por HTTP»Dos rutas, y ninguna más:
| Método | Ruta | Sesión | Éxito |
|---|---|---|---|
GET |
/api/cms/globals/:slug |
Salvo access.read: 'public' |
200 |
PATCH |
/api/cms/globals/:slug |
Siempre | 200 |
Un global sin guardar responde 200 con sus valores por defecto, nunca 404. El detalle —los
códigos de error, por qué no hay ETag y qué contesta un If-Match— está en la
referencia REST.
En el panel
Sección titulada «En el panel»Cada global visible tiene una fila en la barra lateral, con su label. Van después de las
colecciones: dentro de un grupo, detrás de las colecciones que declaran ese mismo admin.group; sin
grupo, en el bloque suelto de arriba, detrás de las colecciones sueltas. Con admin.hidden la fila
desaparece y la URL sigue respondiendo.
Al pasar el ratón por una colección aparece un atajo para crear. En un global no: no hay nada que crear.
La pantalla es /admin/globals/<slug> y es el editor de documentos con lo que un global no tiene:
sin zona de peligro —no se puede borrar—, sin «Volver al listado» —no hay listado— y sin «Creado».
«Modificado» aparece en cuanto se guarda por primera vez. Tras guardar, el aviso dice que el cambio
puede tardar hasta un minuto en verse en otros sitios, que es la verdad de KV. Ver
El panel.
Lo que un global no hace
Sección titulada «Lo que un global no hace»| Cosa | Estado |
|---|---|
| Versiones y borradores | ❌ Guardar publica. #24 |
Hooks (beforeChange, afterChange…) |
✅ Los cuatro de un global. Hooks |
access como función, o cerrar la escritura por rol |
❌ access.read: 'public' y nada más. #22 |
| Localización | ❌ Un global, un idioma. #25 |
Ser destino de un relationship o de un upload |
❌ No está previsto: no tiene tabla |
| Listarse | ❌ No hay /api/cms/globals a secas; se piden por slug |
| Borrarse | ❌ Un global existe mientras esté en el config |
Versiones, access y localización las comparte con las colecciones, y las tres están en
Limitaciones de la versión 1:
borradores y versiones,
control de acceso por roles y
localización. Las tres últimas filas son de un global
y no van a cambiar: son lo que significa «documento único».