Storage Adapters
A storage adapter is the layer that persists CMS content. CaretCMS ships three and lets you write your own.
| Adapter | Use for | Persistence |
|---|---|---|
| Filesystem Default | Local dev, single-server prod, git-tracked content | JSON files on disk |
| In-memory | Tests, ephemeral previews | Process memory (lost on restart) |
| Cloudflare KV | Cloudflare Workers, edge | Cloudflare KV namespace |
| Custom | Postgres, Redis, S3, anything else | You decide |
Picking an adapter
Section titled “Picking an adapter”| Situation | Pick |
|---|---|
| Local dev | Filesystem |
| Single-server prod (Node, Fly, Railway, Render, VPS) | Filesystem with persistent volume |
| Multi-instance Node | Custom (Postgres / Redis) |
| Vercel / Lambda | Custom (DB-backed) — no writable filesystem |
| Cloudflare Workers | Cloudflare KV + R2 |
| Tests | In-memory |
The four adapters
Section titled “The four adapters”The default. Writes JSON files under .caret/data/ and metadata under .caretcms/.
Directory.caret/data/
Directorypages/
- home.json
- about.json
Directorysite/
- global.json
Directory.caretcms/
- revisions.json optimistic-locking counters
Directoryhistory/
Directorypages/
- home.json per-entry history (last 50)
Directorycollections/
- products.json dynamic collection metadata
No setup needed — caret() with no options uses it. The directories are created on first write. Customize roots:
import caret, { filesystemStorage } from '@caretcms/core';
caret({ storage: filesystemStorage({ dataRoot: 'src/content/cms', // git-track content in source metaRoot: 'src/content/cms/.meta', }),})When to use: local dev always. Production when you have one server (or shared NFS), don’t need horizontal scaling, and like content in git.
When not to use: serverless (Workers, Lambda — they don’t have writable disk), multi-instance deployments without shared storage, or anything edge-deployed.
Holds everything in JS maps. Lost when the process restarts.
import caret, { defineStorageProvider } from '@caretcms/core';
caret({ storage: defineStorageProvider({ entrypoint: '@caretcms/core/providers/storage/in-memory', }),})When to use: unit and integration tests, throwaway preview environments, demos. Anywhere you don’t want disk writes polluting state across runs.
The package’s own test suite uses InMemoryAdapter directly:
import { InMemoryAdapter } from '@caretcms/core';
const adapter = new InMemoryAdapter();adapter.preload('pages', [{ id: 'home', data: { headline: 'Hello' } }]);Install the Cloudflare package:
npm install @caretcms/cloudflareimport { defineConfig } from 'astro/config';import cloudflare from '@astrojs/cloudflare';import caret from '@caretcms/core';import { cloudflareStorage, r2Uploads } from '@caretcms/cloudflare';
export default defineConfig({ output: 'server', adapter: cloudflare(), integrations: [ caret({ storage: cloudflareStorage({ binding: 'CMS_KV' }), uploads: r2Uploads({ binding: 'CMS_R2' }), }), ],});Bindings come from wrangler.toml:
name = "my-site"compatibility_date = "2025-01-01"
[[kv_namespaces]]binding = "CMS_KV"id = "<your-kv-namespace-id>"
[[r2_buckets]]binding = "CMS_R2"bucket_name = "my-site-uploads"Storage layout in KV:
collections → ["pages", "site", "products"] (known-collections index)pages::index → ["home", "about"] (per-collection entry index)pages::home → entry JSONpages::home::__rev → revision counterpages::home::__history → revision history arraymeta::products → CollectionMetadata for dynamic collectionsWhen to use: any Cloudflare Workers deploy, edge-distributed sites, anything that scales horizontally.
When not to use: non-Cloudflare hosts. KV’s eventual consistency (60s replication) is fine for content but not for tightly-coupled state.
See Deployment → Cloudflare for the end-to-end setup.
Implement the StorageAdapter interface:
import type { StorageAdapter, EntryData, HistoryEntry, CollectionMetadata,} from '@caretcms/core';
export class PostgresAdapter implements StorageAdapter { // collection / entry reads async discoverCollections(): Promise<string[]> { /* ... */ } async isKnownCollection(collection: string): Promise<boolean> { /* ... */ } async listEntryIds(collection: string): Promise<string[]> { /* ... */ } async listEntries(collection: string): Promise<EntryData[]> { /* ... */ } async readEntry(collection: string, id: string): Promise<EntryData | null> { /* ... */ }
// entry mutations async writeEntry(collection: string, id: string, data: Record<string, unknown>): Promise<void> { /* ... */ } async deleteEntry(collection: string, id: string): Promise<void> { /* ... */ } async reorderEntries(collection: string, order: string[]): Promise<void> { /* ... */ }
// optimistic locking async getRevision(collection: string, id: string): Promise<number> { /* ... */ } async bumpRevision(collection: string, id: string): Promise<number> { /* ... */ }
// history async getHistory(collection: string, id: string): Promise<HistoryEntry[]> { /* ... */ } async appendHistory(collection: string, id: string, entry: HistoryEntry): Promise<void> { /* ... */ }
// dynamic collection metadata async createCollection(metadata: CollectionMetadata): Promise<void> { /* ... */ } async deleteCollection(collection: string): Promise<void> { /* ... */ } async getCollectionMetadata(collection: string): Promise<CollectionMetadata | null> { /* ... */ } async listCollectionMetadata(): Promise<CollectionMetadata[]> { /* ... */ }}Wire it via a provider reference:
import caret, { defineStorageProvider } from '@caretcms/core';
export default defineConfig({ integrations: [ caret({ storage: defineStorageProvider({ entrypoint: './src/cms/postgres-adapter.ts', exportName: 'postgresStorageProvider', options: { connectionString: process.env.DATABASE_URL }, }), }), ],});The integration imports your entrypoint at runtime, calls the named export, and uses whatever it returns. The export can be:
- A factory function — called with
optionsonce, returns an adapter - An adapter instance — used directly
Concurrency contract
Section titled “Concurrency contract”bumpRevision(collection, id) must atomically increment and return the new value. The mutation engine relies on this for optimistic locking.
| Implementation | Atomicity strategy |
|---|---|
| Filesystem | Serialized in-process queue (single process only) |
| KV | Single-key writes, last-write-wins (acceptable in practice for content) |
| Postgres | UPDATE ... RETURNING in a single statement |
| Redis | INCR |
Field validation
Section titled “Field validation”Field paths come from user input. The mutation engine already rejects prototype-pollution attempts (__proto__, constructor.prototype, etc.) before they reach your adapter. You don’t need to re-validate; just trust the inputs you receive in adapter methods.