Ir al contenido

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.

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? No No
¿Necesita migración? 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.

defineGlobal en tu cms.config.ts, y el global en el array globals:

cms.config.ts
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 id
await 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.

.cms/types.d.ts
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.

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.

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.

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 valida

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

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

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 un ConfigError con código MISSING_KV que 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.

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.

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.

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