@molecule/api-project-archive-object-storage
Provider bond · project-archive · API (Node) · v1.0.2 · Apache-2.0
Object-storage project archive provider — gzipped POSIX tar artifact persisted through the bonded uploads provider (S3, R2, B2, MinIO, filesystem), with a post-upload read-back verification gate. Zero external dependencies.
npm install @molecule/api-project-archive-object-storagenpm · Source on GitHub · Implements @molecule/api-project-archive
How it works
@molecule/api-project-archive-object-storage is a provider bond on the API (Node) side: it implements the project-archive core interface (@molecule/api-project-archive) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
import { setProvider } from '@molecule/api-project-archive'
import { provider } from '@molecule/api-project-archive-object-storage'
import { setProvider as setUploads } from '@molecule/api-uploads'
import { provider as s3 } from '@molecule/api-uploads-s3'
setUploads(s3) // storage bond FIRST — this provider composes it
setProvider(provider)Works with: @molecule/api-logger, @molecule/api-project-archive, @molecule/api-uploads
Reference
Object-storage project-archive provider for molecule.dev.
Implements the @molecule/api-project-archive contract by packing a
project's ArchiveParts into a STANDARD gzipped POSIX ustar tarball and
persisting it through the bonded @molecule/api-uploads provider — S3, R2,
B2, MinIO, or the local filesystem bond, whichever the app wired. No storage
SDK is imported here and the package ships zero external runtime
dependencies: the tar writer/reader is a single module of node:zlib +
node:crypto.
The job is exactly this: store some bytes durably, prove they came back, give them back.
The artifact is a real .tar.gz. tar -xzf project.tar.gz yields
manifest.json plus parts/<path> for every archived part (relative paths
and modes preserved) — no molecule.dev tooling required to get the data back
out. That is the no-lock-in promise.
The CONTRACT — what verified means, why the storage id must be persisted,
what restore() guarantees — is documented once, on
@molecule/api-project-archive. What follows is what this BOND adds.
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { setProvider } from '@molecule/api-project-archive'
import { provider } from '@molecule/api-project-archive-object-storage'
import { setProvider as setUploads } from '@molecule/api-uploads'
import { provider as s3 } from '@molecule/api-uploads-s3'
setUploads(s3) // storage bond FIRST — this provider composes it
setProvider(provider)
import type { ArchivePart } from '@molecule/api-project-archive'
import { provider as archiveProvider } from '@molecule/api-project-archive-object-storage'
// WHICH files to archive is the CALLER's decision, and git already answers
// it: .gitignore declares what is disposable, `git clean -Xdf` removes it,
// and `git ls-files` lists what survives. This package has no filter.
await exec('git', ['clean', '-Xdf'], { cwd: dir })
const tracked = await exec('git', ['ls-files', '--cached', '--others', '--exclude-standard'], {
cwd: dir,
})
// ONE generic channel. Source files, a pg_dump and a git bundle are all parts;
// only the caller's path/kind/meta tell them apart, and this provider never
// interprets any of it. Adding a SECOND database is one more part.
const source: ArchivePart[] = await Promise.all(
tracked
.split('\n')
.filter(Boolean)
.map(async (file) => ({
path: `source/${file}`,
content: await readFile(join(dir, file)),
kind: 'source',
})),
)
const previousStorageId = project.archiveStorageId // whatever we persisted last time
const result = await archiveProvider.archive({
projectId,
parts: [
...source, // a .env part here would THROW — see @remarks
{
path: 'database/main.dump',
content: await pgDumpCustom(projectId),
kind: 'database',
meta: { engine: 'postgresql', format: 'pg_custom', database: 'main' },
},
{
path: 'repos/api.bundle',
content: await gitBundle(dir),
kind: 'repo',
meta: { remote: 'origin', headSha: await gitHeadSha(dir) },
},
],
minParts: 1, // an empty part set THROWS
requiredPaths: ['source/package.json', 'database/main.dump'],
metadata: { reason: 'dormant-30d' },
})
if (!result.verified) {
// The failed artifact was already deleted best-effort; orphanCleanup says so.
logger.error('archive not verified — keeping the live project', {
verification: result.verification,
orphanCleanup: result.orphanCleanup,
})
return // never release, and never delete the previous artifact, on this
}
// 1. PERSIST the minted id first — there is no way to find the artifact without it.
await db.projects.update(projectId, { archiveStorageId: result.storageId })
// 2. Now the live project may be released…
await releaseSandbox(projectId)
// 3. …and only now is the OLD artifact safe to delete.
if (previousStorageId && previousStorageId !== result.storageId) {
await archiveProvider.remove(previousStorageId)
}
// Later: bring it back. restore() REQUIRES the persisted storage id and hands
// back BYTES — the caller routes each part by the kind/meta it recorded.
const { parts } = await archiveProvider.restore({
projectId,
storageId: project.archiveStorageId,
})
for (const part of parts) {
if (part.kind === 'database') await pgRestore(db, part.content, part.meta?.format)
else if (part.kind === 'repo') await gitCloneFromBundle(sandbox, part.content)
else await writeFile(sandbox, part.path.replace(/^source\//, ''), part.content, part.mode)
}
Type
provider
Installation
npm install @molecule/api-project-archive-object-storage @molecule/api-logger @molecule/api-project-archive @molecule/api-uploads
API
Interfaces
ProjectArchiveObjectStorageConfig
Configuration for createProjectArchiveProvider.
interface ProjectArchiveObjectStorageConfig {
/**
* The object-storage provider used for persistence. Defaults to the bonded
* `@molecule/api-uploads` provider, resolved LAZILY on every call — so this
* package can be imported (and its `provider` constructed) before
* `setProvider()`/`bond('uploads', …)` runs at startup.
*/
uploads?: UploadProvider
/**
* Whether `archive()` re-reads the artifact out of storage and validates it.
* Defaults to `true`. Setting this to `false` means `archive()` can NEVER
* return `verified: true` — it is an escape hatch for storage backends with no
* `getFile()`, not a performance tweak, and the caller must then treat the
* archive as unverified.
*/
verifyOnArchive?: boolean
/**
* Whether `archive()` may accept an EMPTY part set. Defaults to `false`, so a
* workspace walk that silently returned `[]` throws instead of producing an
* archive of nothing that verifies perfectly.
*
* Turning it on lowers the DEFAULT floor for this provider from `1` to `0`,
* so an empty set is then accepted whether or not the call passes
* `minParts: 0`. Passing it anyway is the recommended habit — it says at the
* call site that an empty archive is expected there — but it is not enforced,
* because the provider-level opt-in is already the explicit act. A call that
* wants the floor back raises it per call (`minParts: 1`).
*/
allowEmpty?: boolean
/**
* Maximum artifact size in bytes, enforced on the artifact this provider
* BUILDS (before upload) and on every DOWNLOADED artifact BEFORE it is
* decompressed. Defaults to 512 MiB.
*/
maxArtifactBytes?: number
/**
* Maximum DECOMPRESSED size in bytes — the decompression-bomb guard. Enforced
* through the tar/gzip codec while inflating and while accumulating entries,
* and on the part bytes handed to `archive()`. Defaults to 2 GiB.
*/
maxUncompressedBytes?: number
}
ArchiveOrphanCleanup
What happened to the just-uploaded object after a verification this provider ATTEMPTED came back false.
A failed verification used to leave the artifact in the bucket forever:
nothing references it (the caller was told not to persist the id of an
unverified archive), nothing will ever read it, and nothing will ever delete
it — a silent, unbounded leak, one object per failed archive. The cleanup is
BEST-EFFORT and never masks the verification failure: verification.error
still says why the archive failed, and this says what became of the bytes.
interface ArchiveOrphanCleanup {
/**
* Whether a delete was attempted at all.
*
* True only when this provider ATTEMPTED a verification and it failed. It is
* deliberately false when verification was SKIPPED (`verifyOnArchive: false`)
* or IMPOSSIBLE (the bonded uploads provider has no `getFile()`): those
* artifacts are unverified by configuration, not by failure, and they are the
* only copy the caller asked for — deleting them would destroy exactly what
* the call was for.
*/
attempted: boolean
/** Whether the object was actually deleted. */
deleted: boolean
/**
* Why the delete failed, when it was attempted and did not succeed.
*
* The object is now an orphan in the bucket; the storage id it names is in
* `ArchiveResult.storageId`, so a caller can retry `remove()` later.
*/
error?: string
}
ObjectStorageArchiveResult
ArchiveResult plus this bond's report on the orphan cleanup.
A superset of the contract's result, so it is still an ArchiveResult
everywhere the contract is used — the extra field only tells a caller what
happened to the bytes of an archive that did not verify.
interface ObjectStorageArchiveResult extends ArchiveResult {
/** What became of the uploaded object when verification failed. */
orphanCleanup: ArchiveOrphanCleanup
}
ObjectStorageProjectArchiveProvider
The ProjectArchiveProvider contract with archive() narrowed to this
bond's richer ObjectStorageArchiveResult.
Assignable to ProjectArchiveProvider in every position (the result is a
superset), so setProvider(provider) and bond('project-archive', provider)
are unaffected — this type exists only so a caller holding the concrete bond
can read orphanCleanup without a cast.
interface ObjectStorageProjectArchiveProvider extends ProjectArchiveProvider {
archive(input: ArchiveInput): Promise<ObjectStorageArchiveResult>
}
Functions
createProjectArchiveProvider(config)
Creates an object-storage-backed project archive provider.
function createProjectArchiveProvider(
config?: ProjectArchiveObjectStorageConfig,
): ObjectStorageProjectArchiveProvider
config— Provider configuration.
Returns: A ProjectArchiveProvider that persists artifacts through the configured (or bonded) uploads provider, with archive() narrowed to ObjectStorageArchiveResult so the orphan-cleanup report is readable without a cast.
Constants
provider
Object-storage project archive provider using the default configuration: the
bonded @molecule/api-uploads provider (resolved lazily, per call),
post-upload verification enabled, empty archives rejected, and the default
512 MiB artifact / 2 GiB decompressed size caps.
Use createProjectArchiveProvider instead when you need different caps,
allowEmpty, or an explicitly injected uploads provider. There is nothing to
configure about WHICH files are archived — that is the caller's decision, made
before the parts are handed over.
Typed as ObjectStorageProjectArchiveProvider — the
ProjectArchiveProvider contract with archive() narrowed to the result that
also reports ArchiveOrphanCleanup — so it stays assignable wherever
the contract is expected (setProvider(provider),
bond('project-archive', provider)) while a caller holding this bond directly
can still read what became of an artifact that failed verification.
const provider: ObjectStorageProjectArchiveProvider
Core Interface
Implements @molecule/api-project-archive interface.
Bond Wiring
Setup function to register this provider with the core interface:
import { setProvider } from '@molecule/api-project-archive'
import { provider } from '@molecule/api-project-archive-object-storage'
export function setupProjectArchiveObjectStorage(): void {
setProvider(provider)
}
Injection Notes
Requirements
Peer dependencies:
@molecule/api-logger>=1.0.1@molecule/api-project-archive>=1.0.1@molecule/api-uploads>=1.0.1
Runtime Dependencies
-
@molecule/api-logger -
@molecule/api-project-archive -
@molecule/api-uploads -
Deciding WHICH files to archive is the CALLER's job, and this package deliberately does not do it. Use git: a project workspace is a repo,
.gitignorealready declares what is disposable,git clean -Xdfremoves it, andgit ls-files --cached --others --exclude-standardlists what survives. Twenty years of solved, universally-understood semantics that your users already write. There is no exclude list, no policy object, no per-ecosystem preset and no filter helper here — every part you handarchive()is archived. The layer that used to do this shipped two silent-data-loss bugs: a directory exclude applied to filenames deletedsrc/build/compiler.ts,src/tmp.ts,src/build.rsandsrc/dist.config.js(real source, no signal), and'\'being a separator to one rule and an ordinary character to another letconfig\.envreach plaintext storage. Neither would have existed if we had used gitignore. -
ONE non-configurable refusal survived that: a part whose path has ANY segment equal to
.env, or starting with.env.(compared CASE-INSENSITIVELY), makesarchive()THROW. It takes no options and has no opt-out because it is a SECURITY property rather than a filtering convenience: the artifact is NOT encrypted at rest, so a dotenv part writes live credentials into plaintext object storage and rotation is the only remedy left — and whether your.gitignorehappens to exclude.envis your choice, which is not a sound basis for a credential outcome. Both widenings are load-bearing, not thoroughness: a basename-only compare archived.env/prod.keyandconfig/.env/staging, and a case-sensitive one archived.ENV,.Envand.eNv.production. Keep secrets in the platform's encrypted vault and re-inject them on restore — including out ofmetadata/meta, which the manifest carries in the clear, and out of a.git/configremote URL. -
The archive is NOT encrypted at rest by this package. It is a plain
.tar.gzsitting in object storage, readable by anyone with bucket access. (Bucket-level SSE, if the deployment has it, is the deployment's guarantee, not this package's.) -
The layout is
manifest.json+ oneparts/<path>member per part, and that is all of it. Every part lives under the ONEparts/prefix, so the archive's namespace and the caller's are disjoint by construction: a part legitimately namedmanifest.jsonbecomesparts/manifest.jsonand can never shadow the artifact's own manifest. Source files, apg_dump, a Redis snapshot and agit bundleare packed, digested, verified and restored by the SAME code path — nothing here branches on a part'skind,meta, or path shape, or decodes its bytes, so a second database or any new content type is one more part and needs no change to this package. -
An artifact may contain NOTHING but those two namespaces, and no DIRECTORY members at all. Anything else is REFUSED by name on every read path (
archive()'s verification,restore(),status()), because nothing counts, digests, verifies or restores it — whiletar -xzfstill creates it. Aparts/<dir>member used to pass the prefix check and then be skipped by the part collector, which is exactly that hole behind the one prefix that looked legitimate. -
A failed verification CLEANS UP after itself. Nothing would ever reference that object again (you were told not to persist the id of an unverified archive), so it is deleted best-effort and
orphanCleanup({ attempted, deleted, error? }) reports what happened; a delete failure is logged and reported, never allowed to maskverification.error. An archive left unverified by CONFIGURATION —verifyOnArchive: false, or an uploads bond with nogetFile()— is never deleted: it is the only copy you asked for. -
ONE path model decides what a path's segments are (
tar.js:normalizePartPath), and path safety, the dotenv refusal and collision detection all read ITS segments.'\'is a separator everywhere, each segment is whitespace-trimmed, repeated separators collapse, and NFC is used for comparison only. A part path that is not already canonical is REJECTED, not rewritten —config\.env,a//b,a/b/,.envand.envall throw — so the path you send is the path the manifest records. When those rules disagreed about'\',config\.envarchived and verified: a live dotenv credential in storage that is not encrypted at rest. Safety is enforced on the RAW path at archive and on the STRIPPED path at restore; checking the prefixed form is worthless, becauseparts/+/etc/passwdis neither absolute nor traversing. -
Size caps are on by default:
maxArtifactBytes(512 MiB) andmaxUncompressedBytes(2 GiB), and everything is buffered in memory.maxArtifactBytesbounds the artifact this provider builds and every one it reads back — enforced WHILE the download streams (the read aborts and the stream is destroyed on the chunk that would cross it) and again BEFORE decompression, so a gzip bomb is rejected unread.maxUncompressedBytesgoes through the codec and also bounds the part bytes handed toarchive(). Both are fine for source plus a dump — single-digit MB — and NOT fine for anode_modules-sized tree, which is exactly whatgit clean -Xdfremoves before you walk. -
.gitis archivable, and history is user work that is not reproducible from a snapshot. For a large repo, archive agit bundlepart instead of the.gitdirectory — either way, keep the history, and scrub credentials out of remote URLs first. -
Bond
@molecule/api-uploadsFIRST, or inject one viacreateProjectArchiveProvider({ uploads }). The bonded provider is resolved lazily per call, so importing this package before the bond is wired is safe. A bond withoutgetFile()can neither verify nor restore —archive()then returnsverified: false(nevertrue), exactly likeverifyOnArchive: false, which is an escape hatch and not a speed knob. -
This bond earns
verified: truethe hard way, running all five contract steps against the bytes storage handed BACK: re-read at the minted id, re-hash, re-parse the manifest, re-count theparts/members, and UNPACK to recompute the parts digest + byte total. A successfulupload()proves nothing about what the bucket holds. What the digest covers, what it cannot (a wholesale re-forge), and the one-column caller-side mitigation are onpartsDigest(artifact.js).