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>; un
group es un campo y por tanto una sola clave— 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: 'array', labels: { singular: 'Enlace', plural: 'Enlaces' }, admin: { description: 'Enlaces extra del menú', rowLabel: 'label' }, fields: [ { name: 'href', type: 'text', required: true }, { name: 'label', type: 'text', required: true }, ], }, ],})
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 quince tipos que una colección, con las mismas opciones salvo hasMany, que no vale en un global (array y blocks se guardan como el JSON de sus filas, cada una con su id), más group y point y los contenedores de maquetación |
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 y en su tarjeta del escritorio |
admin.hidden |
false |
Lo quita de la barra lateral y del escritorio. 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 y la lectura anónima de la API local se sirven sin sesión. Con una función, la decide ella |
access.update |
Exige sesión | Una función que devuelve boolean decide quién guarda. Ver Control de acceso |
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. Y la lectura funciona sin sesión porque site declara access: { read: 'public' }: la API local
es anónima por defecto, y un global sin esa línea lanza UnauthorizedError a quien no dice quién es
(overrideAccess: true si decide el servidor). Ver Control de acceso.
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: { id: string; href: string; label: string }[]}
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: [], // una lista nunca guardada es [], no 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' }, { overrideAccess: true })Guardar exige sesión, y una llamada sin quién la hace es anónima: aquí escribe el servidor, así que lleva
overrideAccess: true. Si guarda una persona, va { user } o la API envuelta con
forUser, y la función access.update del
global decide con { user, data }: sin id, porque un global es un único documento.
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.
group y point en un global
Sección titulada «group y point en un global»Un group es un solo objeto JSON bajo una sola clave:
global:site:contact guarda { "email": "…", "address": { "city": "…" } } entero, no una clave por
subcampo. Un point es un par [lng, lat] bajo la clave del campo. La
API, get() y update() hablan el mismo objeto anidado que en una colección, y select, los errores,
los hooks y el access de un subcampo usan la ruta con puntos: contact.address.city.
await cms.globals.site.update( { contact: { email: 'hola@example.com' }, hq: [-74.07, 4.71] }, { overrideAccess: true },)
await cms.globals.site.get({ select: ['contact.email'] }) // { id, contact: { email } }Lo que cambia respecto a un campo suelto:
- Guardar un subcampo no borra a sus hermanos.
update()funde el grupo subcampo a subcampo con lo guardado, igual que funde el documento campo a campo. Unundefineddentro del grupo cuenta como ausente.contact: nullpone todos los subcampos anull, sin tocar los demás grupos. - Un grupo que nunca se guardó se lee con los
defaultValuede sus subcampos, y unpointsin guardar se leenull. - Una
datedentro de un grupo viaja como ISO y vuelve comoDate, igual que una suelta. Si en KV hay una fecha ilegible, se pierde ese subcampo, se avisa en la consola y sus hermanos se conservan. Lo mismo con unpointque no es un par: se leenull. Y una clave de grupo que no es un objeto se trata como si no existiera. required,access,defaultValuey los hooks de un subcampo se comportan como los de un campo de primer nivel: unrequiredanullesREQUIREDcon la ruta del subcampo, y unuploado unrelationshipdentro de un grupo se hidratan condepthy impiden borrar el fichero al que apuntan.- El panel pinta los grupos y los puntos como en una colección (cómo).
Guardar el global desde el panel funde el grupo subcampo a subcampo: lo que quien guarda no puede ver ni
escribir (un subcampo con
access) se conserva tal cual estaba.
array y blocks en un global
Sección titulada «array y blocks en un global»Una lista es un solo array JSON bajo una sola clave, como un grupo: global:site:nav guarda
[{ "id": "…", "label": "…", "href": "…" }, …] entero. No hay tabla hija ni migración, y la validación, los
códigos y los id de las filas son los de array y blocks en una colección.
await cms.globals.site.update( { nav: [{ label: 'Docs', href: '/docs' }, { label: 'Blog', href: '/blog' }] }, { overrideAccess: true },)
await cms.globals.site.get({ select: ['nav'] }) // { id, nav: [{ id: '0199…', label: 'Docs', … }, …] }Lo que cambia respecto a una colección:
- Una lista guardada antes de tener ids se lee igual. Un
navque erajsoncon[{ href, label }]y pasa aarraysigue leyéndose, sin migración: cada fila recibe su posición comoid("0","1") hasta que se guarda la lista; la demo hizo ese paso y lo cuenta la guía. Si en el config cambias el nombre de un subcampo, las filas antiguas conservan el nombre viejo y el guardado las rechaza conUNKNOWN_FIELDy su ruta, en vez de borrarlo en silencio. - Las fechas de las filas vuelven como
Date, y un valor guardado que no es un array de objetos se lee[](o sudefaultValue) y se avisa en la consola, como una fecha ilegible. depthhidrata losrelationshipy losuploadde las filas con las reglas del primer nivel, y un fichero al que apunta una fila impide su borrado.- El panel edita un
arrayy unblocksfila a fila, y guardar el global desde el panel no pierde las filas: el formulario manda la lista tal como la leyó, ids incluidos. Si una fila trae lo que el config ya no declara (una clave que quitaste, o el tipo de bloque de una fila retirado), el panel lo enseña —«La fila 2 es del bloque «retirado», que ya no existe»— y ofrece «Quitar lo que no se declara». Hasta que lo quitas, guardar falla conUNKNOWN_FIELDoUNKNOWN_BLOCKy la ruta de la fila, y no escribe nada. Cómo se edita, y el formato del valor, están en Editar una lista en el panel.
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', o lo que decida tu función de read |
200 |
PATCH |
/api/cms/globals/:slug |
Siempre, o lo que decida tu función de update |
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. Un global que la persona no puede leer no tiene fila.
También tiene una tarjeta en el escritorio, bajo «Globales», que enlaza a su pantalla. No lleva contador —es un único documento— ni lee KV: ver El escritorio.
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 |
✅ Una función por read y otra por update, de todo o nada, sin condiciones. Control de acceso |
| 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 y localización las comparte con las colecciones, y las dos están en
Limitaciones de la versión 1:
borradores y versiones y
localización; el control de acceso, que un global cumple con
una función de todo o nada, tiene su límite sin access
allí también. Las tres últimas filas son de un global y no van a cambiar: son lo que significa
«documento único».