ADR 0017 — Storage Plugin System
ADR 0017 — Storage Plugin System
Status: Accepted Date: 2026-07-24 Amends: ADR 0016 (extends the plugin type system) Supersedes: ADR 0005 (storage detection no longer lives in the host)
Context
ADR 0005 introduced two storage backends — @alistigo/claude-storage-plugin (backed
by Claude’s window.storage API) and @alistigo/local-storage-plugin (backed by
window.localStorage) — and put the detection logic (which backend is available?) in
App.tsx itself: isClaudeArtifactContext() was called at boot time and the result
wired a concrete repository class directly into the component tree.
ADR 0016 established a composable plugin system for infra/lifecycle concerns (Sentry,
PostHog) where each plugin ships as an independently-versioned npm package, loads from
jsDelivr at runtime, and runs through createPluginRuntime() lifecycle hooks. That
round left storage on the old path — a hard import inside App.tsx, context-detection
logic in the host, and two storage packages that artifact-list had to depend on at
build time.
Three problems with the current arrangement:
- Tight coupling.
App.tsximports from@alistigo/claude-storage-pluginand@alistigo/local-storage-plugin. Adding or replacing a storage backend requires touching the host. - Detection logic in the wrong place. The host knows which backend to use. A plugin system should let the plugin declare its own availability.
- Not extensible. A future storage backend (IndexedDB, remote API) would need
another branch in
App.tsx, not a new plugin package.
Storage backends fit the plugin model naturally: they are independently deployable,
their availability is self-determined (isAvailable()), and the host only needs to
know which one won.
Decision
Plugin type field
AlistigoPlugin gains an optional type field:
export interface AlistigoPlugin { name: string; type?: "monitoring" | "storage" | (string & {}); // … existing lifecycle hooks unchanged …}type is informational metadata — it enables type-based plugin queries (e.g.,
resolveStore()) without coupling the runtime to any specific plugin package. The
open (string & {}) tail lets future plugin types extend the union in a config
document without requiring a package update. Plugins with no type are still valid;
the field is optional.
Storage extension interface
Storage plugins extend AlistigoPlugin with a storage property:
export interface AlistigoStorageExtension extends AlistigoPlugin { type: "storage"; storage: { isAvailable(): boolean; createStore(listId: string): ListStore; listKeys(): Promise<string[]>; seedIfEmpty?(store: ListStore): Promise<void>; // optional };}isAvailable() is synchronous — it must not block the resolution loop. createStore()
returns a ListStore for a given list identifier. listKeys() supports future
multi-list enumeration. seedIfEmpty() is an optional hook for plugins that want to
pre-populate a new store (e.g., from a shared artifact URL).
Plugin spec injection
mount() calls buildPluginSpec(userSpec) before loadPlugins(). That helper
injects the two default storage plugins at the front of the spec, unless the user
has already listed them:
[ "@alistigo/claude-storage-plugin", // first, preferred "@alistigo/local-storage-plugin", // second, fallback ...userSpec plugins]Injection order is intentional: the Claude backend is preferred because it is the only persistent storage available inside a Claude artifact. Placing defaults at the front means a user-provided storage plugin listed in the spec comes third — and can only win if neither default is available. A user who wants to override can explicitly exclude the defaults by listing their own storage plugin before the defaults would appear, or the injection logic can be suppressed via a future escape hatch if a concrete need arises.
Both plugins are CDN-loaded via jsDelivr, following ADR 0016’s @0 major-version-pin
convention:
const url = `https://cdn.jsdelivr.net/npm/${packageName}@0/dist/index.js`;const { default: plugin } = await import(/* @vite-ignore */ url);This means storage backends are never bundled into @alistigo/artifact-list at build
time — enabling or changing a storage backend is a config change, not a rebuild.
Store resolution
After loadPlugins() completes, resolveStore(plugins) selects the active backend:
function resolveStore(plugins: AlistigoPlugin[]): ListStore | null { for (const p of plugins) { if (p.type === "storage" && (p as AlistigoStorageExtension).storage?.isAvailable()) { return (p as AlistigoStorageExtension).storage.createStore(listId); } } return null;}Priority = insertion order. The first plugin in the resolved list where
type === "storage" and isAvailable() === true wins. No scoring, no
weighting — first match takes it.
In-memory fallback
If resolveStore() returns null (no storage plugin loaded, or no plugin reports
itself available), the artifact falls back to InMemoryListStore — a plain
Map-backed implementation with no persistence. No error is thrown; the app starts
normally. Data is lost on page reload. This is acceptable: the fallback is a last
resort, and the CDN-load failure path (storage plugin fails to load) is already
individually try-caught by createPluginRuntime() per ADR 0016 R3.
Rationale
| Criterion | Detection in App.tsx (prior art) | Storage as plugin (chosen) |
|---|---|---|
App.tsx free of storage package imports | ❌ | ✅ |
| Adding a new backend requires host change | ✅ yes, always | ❌ config only |
| Plugin declares its own availability | ❌ host does it | ✅ |
| CDN-load failure is isolated | ❌ breaks mount | ✅ (try-caught, falls to in-memory) |
| Consistent with ADR 0016 extension model | ❌ | ✅ |
Detection in App.tsx was the simplest option when only two backends existed and
no plugin system was in place. With ADR 0016 establishing the pattern, keeping storage
on the old path would create a permanent inconsistency: monitoring plugins are
CDN-loaded, storage plugins are build-time bundled. Moving storage into the same model
closes that gap and makes the extension points uniform.
Consequences
Positive:
App.tsxhas zero imports from storage packages after this change.- A new storage backend requires only a new plugin package and a config entry — no
changes to
artifact-list. - CDN failure on a storage plugin degrades gracefully to the in-memory fallback; it does not prevent the artifact from mounting.
typeonAlistigoPluginenables type-based plugin queries today and leaves a clean extension point for future plugin categories (e.g.,"sync","auth").@alistigo/claude-storage-pluginand@alistigo/local-storage-pluginversion independently ofartifact-listand of each other.
Negative / tradeoffs accepted:
- Storage plugins load from CDN; in a network-constrained environment, there is
a latency window before the store is resolved. The in-memory fallback covers the
failure case but not the latency case — the artifact waits for
loadPlugins()before rendering. buildPluginSpec()injects defaults silently. A developer who forgets the injection exists may be confused by storage plugins appearing in the resolved list without being in their config document. This is mitigated by documentation and by the fact that the injection is the only place default plugins are added.- The
typefield is informational only — it is not enforced at runtime. A plugin that setstype: "storage"but omitsstoragewill causeresolveStore()to skip it (guard onstorage?.isAvailable()), but no error is surfaced at load time. seedIfEmpty()is optional and its call site is left to the host’s discretion; this ADR does not mandate when or whether it is invoked.
Alternatives considered
- Keep detection in App.tsx — rejected: doesn’t scale to additional backends, couples the host to specific storage packages, and creates an inconsistency with the CDN-loaded monitoring plugins established by ADR 0016.
- Single
AlistigoStoragePlugintype (no union) — rejected: thetypefield serves a broader purpose (future plugin categories, type-based queries) and was designed as an open union from the start. A storage-only type would need to be refactored the moment a second non-storage category arises. - Scored / weighted priority — rejected: unnecessary complexity for a two-plugin
default. Insertion-order priority is transparent and predictable; the host controls
order by controlling
buildPluginSpec(). - Mandatory storage plugin (no in-memory fallback) — rejected: it would make CDN failure a hard error, violating the spirit of ADR 0016 R3 (one plugin failing never breaks the host mount). An in-memory store preserves the invariant.