Storage

The pluggable ContentStore abstraction — local JSON files by default, GCS, S3, Azure, R2, Vercel Blob, or Netlify Blobs in production.

Every content type in this project — pages, blog posts, events, links, shop items, contact submissions, uploaded media, and now media galleries — goes through one pluggable interface: ContentStore (src/stores/types.ts). Seven backends implement it, all interchangeable without touching any calling code.

The interface

export interface ContentStore {
  read<T = unknown>(name: string): Promise<T | null>;
  write(name: string, document: unknown): Promise<boolean>;
  describe(): string;
  writeMedia(filename: string, data: Uint8Array, contentType?: string): Promise<string | null>;
  listMedia(prefix: string): Promise<{ name: string; url: string }[]>;
  list(prefix: string): Promise<string[]>;
  delete(name: string): Promise<boolean>;
}

Every method degrades gracefully — “not found” is never an exception. read/write/list/ delete operate on JSON documents (e.g. "blog/welcome.json"); writeMedia/listMedia operate on real media files under an upload/ key namespace, returning a browser-viewable URL. list() is hardcoded to .json files — it’s a content-document lister, not a general file browser; that’s why the media gallery (see Features) needed listMedia() added rather than reusing list().

The seven backends

Backend Selected by Notes
local Default — no cloud env var set JSON files under data/content/; media under public/upload/, served directly by Astro
gcs GRAVITY_SITE_BUCKET Google Cloud Storage. Credentials via Application Default Credentials
s3 GRAVITY_S3_BUCKET AWS S3. Standard AWS credential chain (env vars or IAM role)
azure GRAVITY_AZURE_CONTAINER Azure Blob Storage. Needs AZURE_STORAGE_CONNECTION_STRING too
r2 GRAVITY_R2_BUCKET Cloudflare R2 — speaks the S3 API via @aws-sdk/client-s3. Needs CLOUDFLARE_ACCOUNT_ID + R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY, and GRAVITY_R2_PUBLIC_URL for writeMedia()/listMedia() to return real URLs (R2 has no client-derivable public URL)
vercel_blob BLOB_READ_WRITE_TOKEN (auto-injected once a Blob store is linked) No bucket name to configure
netlify_blob NETLIFY (auto-set by Netlify’s runtime) Media served back via src/pages/upload/[...path].ts, since Netlify Blobs has no public URL of its own

GRAVITY_STORE_BACKEND=local|gcs|s3|azure|r2|vercel_blob|netlify_blob forces one explicitly. getStore() (src/stores/index.ts) is a process-wide singleton — the backend choice is resolved once per process, from whichever env vars are present, in that priority order.

GRAVITY_SITE_PREFIX scopes every read/write/list to a subdirectory (or key prefix), so one bucket/root can be shared by multiple site instances without colliding. Leave it blank for a single site.

Local disk in detail

LocalContentStore (src/stores/local.ts) keeps two separate directories:

  • data/content/ (or GRAVITY_CONTENT_DIR) — JSON documents. Not web-servable.
  • public/upload/ (or GRAVITY_UPLOAD_DIR) — uploaded media, hash-prefixed filenames. Is web-servable, at /upload/<name> — this is Astro’s static directory, served for free, no route code needed.

This distinction matters for anything that needs to serve real files: data/content/ is where the block editor’s own content lives (never served directly), while public/upload/ is where anything a site owner uploads through the admin UI or block image fields ends up, and is genuinely reachable by a browser.

Media galleries: the one feature with a three-way local split

The media gallery (/gallery) is the one feature whose “local” mode isn’t a single thing — see its own section in Features for the full local-disk vs. cloud-bucket vs. Adhara breakdown. The short version: a local-disk gallery scans public/gallery/<slug>/ directly with fs.readdir, bypassing ContentStore entirely (it’s raw static files a site owner drops in, not a JSON document); a cloud-bucket gallery uses the newer listMedia() method instead, reusing the exact same pluggable backend already proven by writeMedia().

Extending storage

Adding a genuinely new content type (not a new backend) gets a new src/services/*Store.ts that calls getStore() — never a second, parallel storage mechanism. Every existing feature follows this pattern: blogStore.ts, eventStore.ts, linkStore.ts, shopStore.ts, galleryStore.ts, contactFormStore.ts, contactStore.ts.

If you need a genuinely new backend (an eighth cloud provider), implement the full ContentStore interface in a new src/stores/<name>.ts, register it in the BUILDERS map in src/stores/index.ts, and add its auto-detect env var to detectBackend()’s priority list.