# How Instatic's Media Storage Registry Supports Pluggable Storage Adapters

> Instatic's Media Storage Registry enables pluggable adapters for custom storage backends. Discover how it dynamically resolves adapters for read and write operations.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: internals
- Published: 2026-07-27

---

**Instatic implements a singleton MediaStorageRegistry that maintains a Map of storage adapters, coupled with a database-backed per-role election system, enabling plugins to register custom storage backends via a QuickJS bridge while the host dynamically resolves the correct adapter for each read and write operation.**

Instatic is an extensible content management system that treats media storage as a pluggable concern. Rather than hardcoding cloud providers or filesystem paths, the platform delegates all asset persistence to a **media storage registry** that accepts contributions from third-party plugins. This architecture allows operators to swap storage backends—whether local disk, Amazon S3, or custom CDNs—without modifying core application code.

## Core Architecture of the Storage Registry

The pluggable adapter system rests on four coordinated layers: a singleton registry for adapter instances, a database table for per-role elections, a standardized two-phase upload contract, and a QuickJS bridge for plugin registration.

### The MediaStorageRegistry Singleton

At the heart of the system lies the `MediaStorageRegistry`, implemented as a singleton in [`src/core/plugins/mediaStorageRegistry.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/mediaStorageRegistry.ts). This registry maintains an internal `Map<string, MediaStorageAdapter>` that holds every registered adapter, including the built-in local-disk adapter and any adapters contributed by plugins.

The registry provides three critical methods:

- `configureLocalDisk()` – Bootstraps the sentinel local adapter with an empty string id (`''`).
- `resolve(adapterId, role)` – Retrieves an adapter for write operations, validating that it supports the requested role.
- `resolveForRead(storageAdapterId)` – Looks up the adapter associated with a specific asset during publish or serve operations.

When a plugin is disabled, `unregisterPlugin(pluginId)` removes every adapter whose id is prefixed with that plugin's identifier, ensuring clean lifecycle management.

### Per-Role Adapter Election

Storage adapters are not selected globally; instead, Instatic persists a **per-role election** in the database table `active_media_storage_adapter`. As implemented in [`server/repositories/mediaStorageAdapters.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/mediaStorageAdapters.ts), the `electAdapter(db, role, adapterId, userId)` function writes the elected adapter for a specific role such as `original`, `variant`, or `avatar`.

During upload initiation, `getElectedAdapterId(db, role)` reads the current election once and pins that adapter to the asset for its entire lifetime. This guarantees that an asset's storage backend remains stable even if the election changes later.

### The Two-Phase Upload Contract

All adapters implement the `MediaStorageAdapter` interface defined in [`src/core/plugin-sdk/types/media.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugin-sdk/types/media.ts). This contract enforces a two-phase upload process:

1. **`beginWrite(input)`** – Returns a `MediaStorageUploadPlan` containing one or more HTTP steps (`PUT`/`POST`) or a sentinel `LOCAL` step for the built-in adapter. The host streams bytes directly to the provided URLs.
2. **`finalizeWrite(input)`** – Called after successful byte transfer, returning the public URL or internal path.

This design allows adapters to generate pre-signed URLs for external services (like S3) while letting the host handle the actual byte streaming.

### Plugin Registration via QuickJS Bridge

Plugins running in the QuickJS sandbox register adapters by calling `api.cms.media.registerStorageAdapter(adapter)`. The host-side validation logic in [`server/plugins/quickjs/bootstrap/src/buildApi.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/bootstrap/src/buildApi.ts) (approximately lines 252–300) ensures:

- The adapter object contains required fields: `id`, `label`, `roles`, `servingMode`, and callback functions.
- The adapter id is namespaced under the plugin id (`${pluginId}.<adapterName>`).

Once validated, the bridge stores the adapter in the `MediaStorageRegistry`, making it available for election immediately.

### Read-Time Resolution

When serving media, the system uses the `storage_adapter_id` column stored in the `media_assets` table (managed by [`server/repositories/media.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/repositories/media.ts)). The publishing pipeline in [`server/publish/mediaPresentation.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/mediaPresentation.ts) calls `mediaStorageRegistry.resolveForRead(asset.storageAdapterId)` to retrieve the correct adapter and generate the final public URL. No role check occurs during reads because the role was already validated during the upload phase.

## The Built-in Local-Disk Adapter

Instatic ships with a default adapter registered at boot via `configureLocalDisk()`. This adapter uses the sentinel id `''` and writes directly to the server's uploads folder, serving files via the static `/uploads/*` handler.

Its `beginWrite` implementation returns a single step whose `method` equals the internal constant `LOCAL_DISK_STEP_METHOD` and whose URL uses the `file://` scheme. The upload executor detects this sentinel and writes the file using Node's `fs.promises.writeFile`, bypassing HTTP streaming for local operations.

## Pluggable Adapter Lifecycle

Adapters move through a strict lifecycle governed by the registry and the election repository:

1. **Registration** – Plugins invoke `api.cms.media.registerStorageAdapter(adapter)` during initialization.
2. **Validation** – The QuickJS bridge verifies the adapter shape and enforces the `${pluginId}.` namespace prefix.
3. **Storage** – The validated adapter enters the `MediaStorageRegistry` Map.
4. **Election** – Administrators use the `electAdapter` repository function to assign the adapter to specific roles like `original` or `variant`.
5. **Unregister** – When a plugin disables, `mediaStorageRegistry.unregisterPlugin(pluginId)` removes all associated adapters from the Map. The built-in local adapter remains protected and is never removed.

## Implementation Examples

### Creating a Custom S3 Adapter

Plugins define adapters by implementing the `MediaStorageAdapter` interface from `@core/plugin-sdk`. The following example registers an S3-compatible adapter that generates pre-signed PUT URLs:

```typescript
// src/plugins/my-s3-plugin/mediaAdapter.ts
import type {
  MediaStorageAdapter,
  MediaStorageBeginWriteInput,
  MediaStorageUploadPlan,
  MediaStorageFinalizeWriteInput,
  MediaStorageWriteResult,
} from '@core/plugin-sdk'

export const s3Adapter: MediaStorageAdapter = {
  id: 'my-s3-plugin.s3',
  label: 'Amazon S3',
  roles: ['original', 'variant'],
  servingMode: 'public-url',
  async beginWrite(input: MediaStorageBeginWriteInput): Promise<MediaStorageUploadPlan> {
    const uploadUrl = await getSignedPutUrl(input.suggestedStoragePath)
    return {
      storagePath: input.suggestedStoragePath,
      steps: [{ method: 'PUT', url: uploadUrl, headers: {} }],
      expiresAt: Date.now() + 10 * 60 * 1000,
    }
  },
  async finalizeWrite(input: MediaStorageFinalizeWriteInput): Promise<MediaStorageWriteResult> {
    return { publicUrl: `https://my-bucket.s3.amazonaws.com/${input.storagePath}` }
  },
  async abortWrite({ storagePath }) {
    await deletePartialObject(storagePath)
  },
  async delete(storagePath) {
    await deleteObject(storagePath)
  },
  async verify() {
    const ok = await canListBucket()
    return ok ? { ok: true } : { ok: false, reason: 'S3 unreachable' }
  },
}

// Register from plugin entry point
export function initPlugin(api: any) {
  api.cms.media.registerStorageAdapter(s3Adapter)
}

```

### Electing the Adapter for a Specific Role

Server-side code or admin UIs elect adapters by writing to the `active_media_storage_adapter` table via the repository layer:

```typescript
import { electAdapter } from '@/server/repositories/mediaStorageAdapters'
import { db } from '@/server/db/client'

async function setS3ForOriginal(userId: string) {
  await electAdapter(db, 'original', 'my-s3-plugin.s3', userId)
}

```

### Resolving Adapters at Runtime

During upload handling, the host resolves the elected adapter and delegates the operation:

```typescript
import { getElectedAdapterId } from '@/server/repositories/mediaStorageAdapters'
import { mediaStorageRegistry } from '@/core/plugins/mediaStorageRegistry'

async function handleUpload(role: MediaAssetRole, input: MediaStorageBeginWriteInput) {
  const adapterId = await getElectedAdapterId(db, role)
  const adapter = mediaStorageRegistry.resolve(adapterId, role)
  if (!adapter) throw new Error('No adapter for role')
  const plan = await adapter.beginWrite(input)
  // Host streams bytes to plan.steps[0].url
}

```

## Summary

- **MediaStorageRegistry** maintains a singleton Map of all storage adapters in [`src/core/plugins/mediaStorageRegistry.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/mediaStorageRegistry.ts), providing resolution methods for reads and writes.
- **Per-role elections** stored in the `active_media_storage_adapter` table determine which adapter handles uploads for specific asset types like `original` or `avatar`.
- **Plugin registration** occurs via `api.cms.media.registerStorageAdapter()`, validated in [`server/plugins/quickjs/bootstrap/src/buildApi.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/bootstrap/src/buildApi.ts) and namespaced under the plugin id.
- **Two-phase upload contract** requires adapters to implement `beginWrite` (returning upload plans) and `finalizeWrite` (returning public URLs), standardizing integration with external storage.
- **Lifecycle management** ensures adapters unregister automatically when plugins disable, while the built-in local-disk adapter remains permanently available.

## Frequently Asked Questions

### How does Instatic ensure that changing a storage adapter does not affect existing media assets?

Instatic pins the `storage_adapter_id` to each asset record in the `media_assets` table at upload time. When `getElectedAdapterId` selects an adapter during `beginWrite`, that identifier is stored with the asset. Subsequent read operations in [`server/publish/mediaPresentation.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/publish/mediaPresentation.ts) use `mediaStorageRegistry.resolveForRead()` to fetch the adapter associated with the stored id, ensuring existing assets continue to resolve through their original backend even if the active election changes.

### Can a single plugin register multiple storage adapters?

Yes. A plugin may call `api.cms.media.registerStorageAdapter()` multiple times with distinct adapter objects, provided each `id` is unique and prefixed with the plugin's id (e.g., `my-plugin.s3-eu` and `my-plugin.s3-us`). The registry stores each entry separately in the internal Map, and administrators can elect different adapters for different roles (e.g., `original` in EU, `variant` in US).

### What validation occurs when a plugin registers a storage adapter?

The QuickJS bridge in [`server/plugins/quickjs/bootstrap/src/buildApi.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/plugins/quickjs/bootstrap/src/buildApi.ts) validates that the adapter object includes mandatory fields: `id`, `label`, `roles` array, `servingMode`, and the required callback methods (`beginWrite`, `finalizeWrite`, `delete`, `verify`). It also enforces that the adapter id begins with the plugin's id followed by a dot (`${pluginId}.`), preventing namespace collisions between plugins.

### How does the built-in local adapter differ from pluggable adapters in its upload handling?

While pluggable adapters typically return HTTP URLs in their upload plans, the built-in local adapter returns a sentinel step with `method` set to `LOCAL_DISK_STEP_METHOD` and a `file://` URL. The host executor detects this sentinel in [`src/core/plugins/mediaStorageRegistry.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/core/plugins/mediaStorageRegistry.ts) and writes the file directly using `fs.promises.writeFile`, bypassing network streaming and storing the asset in the server's local uploads directory.