Skip to content

Pluggable StorageBackend API + extract @adobe/helix-shared-storage-s3 - #1260

Merged
tripodsan merged 12 commits into
mainfrom
pluggable-storage-backend
Sep 1, 2026
Merged

Pluggable StorageBackend API + extract @adobe/helix-shared-storage-s3#1260
tripodsan merged 12 commits into
mainfrom
pluggable-storage-backend

Conversation

@tripodsan

@tripodsan tripodsan commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #1258.

  • Refactors @adobe/helix-shared-storage onto a pluggable StorageBackend interface (AbstractStorageBackend, MirroringBackend, a thin Bucket facade, all JSDoc-typed). Storage (renamed from HelixStorage) is now configured with a single backendFactory closure instead of hardcoded S3/R2 constructor options, and core has zero cloud-SDK dependency (not even transitively/dev).
  • Extracts the existing S3/R2 implementation into a new package, @adobe/helix-shared-storage-s3, exporting S3Backend, createDefaultBackendFactory, and a StorageS3 subclass (renamed from HelixStorageS3) that pre-wires the default backendFactory so fromContext() keeps its familiar single-argument call shape.
  • @adobe/helix-shared-storage-azure (a reference implementation for a second backend family) is intentionally out of scope for this PR — deferred to a follow-up, per the issue's own phased design.
  • All .d.ts files are removed from both packages; types are now JSDoc-only (@typedef/@property), matching the convention already used by most other packages in this repo (only helix-shared-tokencache ships hand-written .d.ts, and nothing in the monorepo type-checks them).

Correctness fixes and design cleanups surfaced during review:

  • MirroringBackend's fan-out error tagging now identifies the failing backend by identity instead of array position (the old sendToS3andR2 only inferred [S3]/[R2] correctly by luck, for exactly 2 clients).
  • putMeta() is now a backend-owned, mandatory primitive (not a copy()-based generic default), and a new symmetric getMeta() lets callers round-trip metadata updates without needing to know which fields are backend-specific "system properties" vs. custom metadata (meta.contentType auto-maps onto S3's Content-Type, for example).
  • Every backend method that returns a CommonObjectMeta-shaped value (head/put/copy/putMeta) now nests the backend's native SDK response under a dedicated raw property instead of spreading it into the top level, avoiding casing collisions with the common field vocabulary.
  • S3Backend.copy() merges raw backend-native passthrough options (copyOpts) without letting an absent or head-derived common field (e.g. contentType) clobber an explicit raw value the caller passed (e.g. copyOpts.ContentType), in either direction, and without leaking the lowerCamelCase common fields (or undefined-valued placeholders) into the actual CopyObjectCommand input.

Migration for existing S3/R2 consumers

- import { HelixStorage } from '@adobe/helix-shared-storage';
+ import { StorageS3 as Storage } from '@adobe/helix-shared-storage-s3';

No other call-site changes are required for fromContext()-based usage. Consumers constructing Storage directly (bypassing fromContext) pass an explicit backendFactory: createDefaultBackendFactory(env).

Breaking changes (major version bump for @adobe/helix-shared-storage)

  • The exported class is renamed HelixStorageStorage (and, in the new package, HelixStorageS3StorageS3).
  • new Storage(opts) no longer defaults to an S3(+R2) backend — it requires an explicit backendFactory, or bucket()/the named bus accessors throw.
  • Storage.s3() is removed from core (use bucket().client instead).
  • HelixStorage.AWS_S3_SYSTEM_HEADERS is removed entirely (it was a dead static, defined but never read anywhere in this repo).
  • bucket()/contentBus()/codeBus()/sourceBus()/mediaBus()/configBus()'s second argument is now a generic, opaque options bag (e.g. { disableR2: true }) forwarded verbatim to the backend factory, instead of a hardcoded disableR2 boolean parameter — core no longer needs to know about R2-specific vocabulary.
  • bucket.head(key) no longer returns the raw S3 SDK response; it returns a backend-agnostic CommonObjectMeta (lowerCamelCase fields — contentType, contentEncoding, cacheControl, contentDisposition, expires, contentLanguage, etag, versionId, contentLength, lastModified, metadata), with the original raw SDK response available under .raw.
  • bucket.get(key, meta)'s output meta object is now populated with the same lowerCamelCase common field names as head(), instead of the raw PascalCase S3 response field names (e.g. meta.contentType instead of meta.ContentType).
  • bucket.putMeta(path, meta, opts) drops the third opts parameter (putMeta(path, meta)); it fully replaces metadata rather than merging, and now auto-maps recognized system-property keys in meta onto the backend's native system properties instead of writing them as literal custom metadata. Use the new getMeta() to round-trip an existing object's metadata safely.
  • All shipped .d.ts files are removed; TypeScript consumers relying on the previous hand-written types lose them (JSDoc types are exported via .js source but not surfaced as a .d.ts type root).

Test plan

  • npx nx run-many -t test,lint passes for all 17 workspace projects.
  • @adobe/helix-shared-storage retains 100% line/branch/function/statement coverage with a new fake-backend-based test suite (no HTTP, no cloud SDK).
  • @adobe/helix-shared-storage-s3 retains 100% coverage; the full pre-existing ~1820-line nock-based S3/R2 integration suite was migrated and passes unmodified in behavior (aside from documented, intentional changes noted above).
  • Confirmed zero cloud-SDK footprint in core: grep -rn "@aws-sdk\|@smithy" packages/helix-shared-storage/package.json packages/helix-shared-storage/src returns nothing.
  • Confirmed packages/helix-shared-storage-s3/.releaserc.cjs is a real symlink to ../../.releaserc.cjs, matching every other package's convention.

🤖 Generated with Claude Code

…-shared-storage-s3

HelixStorage/Bucket previously only knew how to talk to AWS S3, with R2
support hardcoded as a second S3Client mirrored via sendToS3andR2. This
refactors @adobe/helix-shared-storage onto a pluggable StorageBackend
interface (StorageBackend.d.ts, AbstractStorageBackend, MirroringBackend,
Bucket facade) so other backends (Azure, GCS, ...) can ship as separate
packages without core depending on any cloud SDK. HelixStorage is now
configured with a single backendFactory closure instead of hardcoded
S3/R2 options.

The existing S3/R2 implementation is extracted into a new package,
@adobe/helix-shared-storage-s3, which exports S3Backend,
createDefaultBackendFactory, and a HelixStorageS3 subclass that
pre-wires the default backendFactory so fromContext() keeps its
single-argument signature.

Along the way: MirroringBackend's error tagging is fixed to identify
the failing backend by identity instead of by position (previously
only correct by luck for exactly 2 clients), and S3Backend.copy()
correctly merges raw backend-native passthrough options (copyOpts)
without letting absent common fields clobber explicit values.

Resolves #1258.

Existing S3/R2 consumers migrate with a one-line import change:

  - import { HelixStorage } from '@adobe/helix-shared-storage';
  + import { HelixStorageS3 as HelixStorage } from '@adobe/helix-shared-storage-s3';

No other call site changes are required for fromContext()-based usage.

Tested: full workspace test/lint suite (17 projects) passes; both
touched packages retain 100% line/branch/function/statement coverage.

BREAKING CHANGE: `new HelixStorage(opts)` in @adobe/helix-shared-storage
no longer defaults to an S3(+R2) backend - it requires an explicit
`backendFactory`, or throws. Direct constructors must pass
`backendFactory: createDefaultBackendFactory(env)` from the new
@adobe/helix-shared-storage-s3 package. `HelixStorage.s3()` and the
`HelixStorage.AWS_S3_SYSTEM_HEADERS` static are removed from core
(the latter moves, unchanged, to `HelixStorageS3.AWS_S3_SYSTEM_HEADERS`
in @adobe/helix-shared-storage-s3). `Bucket.client`/`HelixStorage.bucket()`'s
second argument is now a generic options bag instead of a `disableR2`
boolean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tripodsan

Copy link
Copy Markdown
Contributor Author

see https://github.com/adobe/helix-admin/pull/3735 for the changes required in helix-admin to migrate

Comment thread packages/helix-shared-storage-s3/src/S3Backend.js Outdated
Comment thread packages/helix-shared-storage-s3/src/S3Backend.js Outdated
@dominique-pfister

dominique-pfister commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

Two things:

  • Just call HelixStorage => Storage, and HelixStorageS3 => StorageS3 (or S3Storage along the line of S3Backend). The Helix prefix does not really provide useful information, and it's already contained in the package name
  • Skip the .d.ts files, they open up when I click in my IDE instead of the actual implementation which is annoying

@dominique-pfister dominique-pfister left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See comment

Rename per PR review feedback:
- @adobe/helix-shared-storage's `HelixStorage` -> `Storage`
- @adobe/helix-shared-storage-s3's `HelixStorageS3` -> `StorageS3`
  (file renamed HelixStorageS3.js -> StorageS3.js, likewise for its test)

Also removes every .d.ts file from both packages. Nothing in this
monorepo type-checks .d.ts against anything (only helix-shared-tokencache
even ships them), so a hand-written, unenforced type surface for just
these two packages wasn't worth maintaining. Both packages are now
plain JSDoc-commented JS, matching helix-shared-async/helix-shared-string/
etc. Dropped the "types" field from both package.json files and cleaned
up now-dangling `@typedef {import('./x.d')...}`/`@implements` JSDoc tags.

Also rewrote packages/helix-shared-storage/README.md, which still
documented the pre-refactor, S3-specific constructor API (this predates
the pluggable-backend work and was missed in the original PR).

No behavioral changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown

This PR will trigger a major release when merged.

Re-adds the type structures from the .d.ts files deleted in the
previous commit, as explicit @typedef blocks colocated in the .js
files that own each concept, following this repo's existing
JSDoc-only convention (see helix-shared-git/src/GitUrl.js).

- AbstractStorageBackend.js: CommonObjectMeta, PutOptions, CopyOptions
  (backend-level), RemoveOptions, RemoveResult, BulkRemoveResult,
  BackendListOptions, and the StorageBackend interface shape itself.
- MirroringBackend.js: MirroringBackendOptions.
- Bucket.js: BucketOptions, RawObjectMeta; per-method @param/@returns
  now reference the above plus storage.js's public types.
- storage.js: ObjectInfo, ObjectFilter, CopyOptions (public,
  Bucket-facing), BrowseOptions, ListOptions, ListResult,
  BulkDeleteResult, StorageOptions, StorageContext, BucketMap.
- helix-shared-storage-s3/S3Backend.js: S3BackendOptions; per-method
  @param/@returns reference core's types via the published package
  specifier.
- helix-shared-storage-s3/createDefaultBackendFactory.js:
  CreateDefaultBackendFactoryOptions.
- helix-shared-storage-s3/StorageS3.js: typed fromContext() override.

Still no .d.ts files and no "types" field — this is documentation via
JSDoc comments only, consistent with the rest of the monorepo. No
behavioral changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
tripodsan and others added 9 commits September 1, 2026 10:05
putMeta was previously a generic default on AbstractStorageBackend,
implemented as a self-copy composed through backend.copy(). That
composition needed the caller's raw opts nested under a copyOpts key
to reach copy(), while Bucket's own copy()/copyDeep() flatten
copyOpts at the top level — the exact mismatch fixed in the prior
commit. Removing the generic default removes that leaky abstraction
entirely.

putMeta now joins get/head/put/copy/remove/list as a mandatory
primitive every backend must implement directly:
- S3Backend.putMeta() implements the self-copy CopyObjectCommand
  itself (restoring the exact shape of the pre-refactor
  Bucket.putMeta()), independent of S3Backend.copy().
- S3Backend.copy() no longer needs to special-case a nested
  `opts.copyOpts` (nothing produces that shape anymore), so that
  accommodation is removed.
- AbstractStorageBackend.putMeta() is now a throwing stub, matching
  the other 6 mandatory primitives.
- MirroringBackend is unaffected — it already fans `putMeta` out
  generically regardless of whether it's mandatory or defaulted.

Updated tests: AbstractStorageBackend.test.js's MinimalBackend no
longer needs a copy() stub just to exercise putMeta's old delegation;
Bucket.test.js's FakeBackend now implements putMeta directly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Previously head()/put()/copy() spread the backend's raw native SDK
response (`...raw`) directly into the returned CommonObjectMeta
object, relying on lowerCamelCase-vs-PascalCase casing to avoid
collisions with the common fields. putMeta() returned the raw
response entirely unwrapped, with no common fields at all.

Both patterns leak backend-specific shape onto the top level of every
return value. Nest the raw response under a single `raw` property
instead, on S3Backend's head()/put()/copy()/putMeta():

  return { etag: raw.ETag, versionId: raw.VersionId, ..., raw };

Callers who need backend-native fields now reach for `result.raw.X`
(e.g. `result.raw.ChecksumCRC64NVME`) instead of relying on an
implicit, casing-based non-collision guarantee.

Bucket.copy()'s pre-existing `result.CopyObjectResult ?? result`
narrowing (kept for byte-for-byte backward compat with the
pre-refactor Bucket.copy() return shape) now reads
`result.raw?.CopyObjectResult ?? result.raw ?? result` to match.

CommonObjectMeta's typedef documents `raw` as an explicit property
instead of an open-ended "plus any other raw fields" note; Bucket.js's
now-redundant `RawObjectMeta` alias (previously
`CommonObjectMeta & Record<string, unknown>`) collapses to a plain
`CommonObjectMeta` re-export, since there's nothing left to intersect.

Scope: head/put/copy/putMeta only. get()'s `meta` out-param keeps its
existing, separate mechanism (selectively copying named AWS_META_HEADERS
fields), left unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
StorageBackend#putMeta(key, meta, opts) leaked S3 semantics onto the
common interface: `opts` was documented as "raw, backend-native
fields merged into the underlying call" only because S3 has no
dedicated metadata-update API — putMeta fakes one via a self-copy
CopyObjectCommand, whose input happens to accept both system
properties (ContentType, ContentDisposition, ...) and custom metadata
together. Azure Blob Storage has no equivalent: it's two separate
calls, setHTTPHeaders() for system properties and setMetadata() for
custom metadata, with no way to merge arbitrary raw fields into
either. The issue's own reference design for an Azure putMeta
override was simply `setMetadata(meta)` - no opts at all.

putMeta is now `(key, meta)` only, on both the StorageBackend
interface and Bucket#putMeta - no raw-passthrough parameter. Within
S3Backend#putMeta, `meta` keys matching a small SYSTEM_META_FIELDS map
(contentType, contentDisposition, contentEncoding, contentLanguage -
the same 4 fields StorageS3.AWS_S3_SYSTEM_HEADERS already names) are
routed onto the corresponding CopyObjectCommand system field instead
of becoming custom `x-amz-meta-*` metadata; everything else is custom
metadata. This fakes Azure's two-call split within S3's one
self-copy call, so a future AzureBlobBackend's putMeta can do the
equivalent split against its own two real calls (setHTTPHeaders +
setMetadata) instead of needing a raw-opts escape hatch.

Drops the ability to set arbitrary raw S3 fields (e.g. Tagging) via
putMeta through the common interface - tagging isn't a metadata
concept on any backend, so this is intentional, not a regression to
paper over.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This public static was carried over unchanged from core's pre-refactor
HelixStorage.AWS_S3_SYSTEM_HEADERS, itself dead code even before this
PR (defined, never read internally). S3Backend now has a real,
functionally-used equivalent (SYSTEM_META_FIELDS in S3Backend.js,
driving putMeta()'s system-vs-custom-metadata split), so keeping this
inert, duplicate public reference around no longer serves any purpose.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a symmetric getMeta(key) alongside putMeta(key, meta), solving
the "can't safely update metadata without knowing S3-specific system
fields" problem: since head() already normalizes system properties
into the same common lowerCamelCase names putMeta() recognizes,
getMeta() is a purely generic AbstractStorageBackend default (no
backend-specific code, same pattern as metadata()/listFolders()/
browse()) that merges head()'s recognized system fields with its
custom metadata into one flat, putMeta()-compatible bag:

  const meta = await bucket.getMeta(key);
  meta.foo = 'updated';
  await bucket.putMeta(key, meta);

This round-trips correctly without the caller ever needing to
distinguish "system" from "custom" fields.

For this round-trip to actually be correct, head()'s common fields
and putMeta()'s recognized system fields needed to be the *same* set
- previously they were two different, only partly-overlapping lists
(head() had contentType/contentEncoding/cacheControl/
contentDisposition/expires; putMeta() had contentType/
contentDisposition/contentEncoding/contentLanguage). Unified into one
6-field SYSTEM_META_FIELD_NAMES list (adding contentLanguage,
keeping cacheControl/expires), exported from core and used
consistently by S3Backend's head()/put()/copy()/putMeta() - also
letting S3Backend derive its PascalCase field-name mapping
programmatically instead of hand-duplicating the list four times.

MirroringBackend now proxies getMeta() to primary alongside the other
read-only methods (get/head/metadata/list/listFolders/browse).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
S3Backend#get()'s optional `meta` out-param was still copying raw AWS
PascalCase field names (CacheControl, ContentType, ContentEncoding,
ETag, Expires, LastModified) directly onto the caller-provided
object, inconsistent with head()/put()/copy()/putMeta()/getMeta(),
which all use the common lowerCamelCase vocabulary
(SYSTEM_META_FIELD_NAMES) established earlier in this PR.

GET_META_FIELDS is now derived from the existing SYSTEM_META_FIELDS
map (reversed) plus the two read-only object attributes get() has
always also surfaced (etag, lastModified), so get()'s meta output
uses the exact same field names as head()'s CommonObjectMeta.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…AD-derived values

S3Backend#copy() only guarded against clobbering an explicit
copyOpts.ContentType (etc.) with an *absent* common-field value
(opts.contentType === undefined). It didn't guard the other
direction: when Bucket#_buildCopyOptions() populates the common
fields from the source's HEAD (the addMetadata/renameMetadata path,
needed to preserve system properties across the self-copy
REPLACE), a *present* HEAD-derived value silently overwrote an
explicit copyOpts override for the same field, since both travel
through the same opts.contentType slot.

Reproduced: bucket.copy(src, dst, { addMetadata: {...}, copyOpts:
{ ContentType: 'application/pdf' } }) against a source whose HEAD
reports 'text/plain' actually sent 'text/plain', silently discarding
the caller's explicit override.

Fix: only apply a common-field-derived value onto the raw
CopyObjectCommand input when that raw field isn't already set (i.e.
input[key] === undefined) - an explicit copyOpts value, however it
got into opts, now always wins over anything HEAD-derived.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…efined ones, into S3 CopyObjectCommand input

Bucket._buildCopyOptions() unconditionally assigned head[field] onto
copyOptions even when the source's HEAD lacked that system property,
leaving explicit `undefined`-valued properties on the options bag.
S3Backend.copy() then spread that whole options bag into the raw
CopyObjectCommand input, so the resulting input carried both the
lowerCamelCase common fields (contentType, metadata, metadataDirective,
...) and their mapped PascalCase equivalents side by side. Harmless to
S3 (unrecognized fields are ignored) but confusing to debug.

_buildCopyOptions() now only copies a system field from HEAD when it's
actually defined, and S3Backend.copy() excludes the known common field
names from the raw passthrough spread before mapping them onto their
PascalCase equivalent, so the command input only ever contains the
backend-native fields.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@tripodsan
tripodsan merged commit b06f628 into main Sep 1, 2026
6 checks passed
@tripodsan
tripodsan deleted the pluggable-storage-backend branch September 1, 2026 15:00
@tripodsan

Copy link
Copy Markdown
Contributor Author

🎉 This PR is included in version @adobe/helix-shared-storage-v3.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

@tripodsan

Copy link
Copy Markdown
Contributor Author

🎉 This PR is included in version @adobe/helix-shared-storage-s3-v1.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pluggable storage backend API for @adobe/helix-shared-storage (S3/R2, Azure, etc. as peer packages)

2 participants