← All @molecule/* packages · App templates

@molecule/api-project-archive

Core interface · project-archive · API (Node) · v1.0.1 · Apache-2.0

Project archive core interface — pack a dormant project (source + database dump) into a verified artifact in cold storage and restore it on demand, via swappable providers.

npm install @molecule/api-project-archive

npm · Source on GitHub

How it works

@molecule/api-project-archive is the project-archive core interface on the API (Node) side: the API your app calls, with no vendor inside.

Choose the implementation by bonding one of its 5 providers: @molecule/api-project-archive-external-state-d1, @molecule/api-project-archive-external-state-mysql, @molecule/api-project-archive-external-state-postgresql, @molecule/api-project-archive-external-state-sqlite, @molecule/api-project-archive-object-storage.

import { type ArchivePart, requireProvider, setProvider } from '@molecule/api-project-archive'
import { provider as objectStorageArchive } from '@molecule/api-project-archive-object-storage'

// Wire at startup (equivalently: bond('project-archive', objectStorageArchive)).
setProvider(objectStorageArchive)

// …later, reaping a project that has been dormant for 30 days.
const archiveStore = requireProvider()
const previousStorageId = project.archiveStorageId // whatever we persisted last time

// WHICH files to archive is OUR call, and git already answers it: drop
// everything .gitignore calls disposable, then list what is left. No exclude
// list lives in the archive package.
await exec('git', ['clean', '-Xdf'], { cwd: dir })
const tracked = await exec('git', ['ls-files', '--cached', '--others', '--exclude-standard'], {
  cwd: dir,
})

// ONE generic channel. Source, a database dump and a git bundle are all parts —
// the archive stores their bytes verbatim and never interprets `kind`/`meta`.
// (`git ls-files` does not list history: archive a bundle for that.)
const parts: ArchivePart[] = [
  ...(await Promise.all(
    tracked
      .split('\n')
      .filter(Boolean)
      .map(async (file) => ({
        path: `source/${file}`,
        content: await readFile(join(dir, file)),
        kind: 'source',
      })),
  )),
  {
    path: 'database/main.dump',
    content: await pgDumpCustom(projectId), // pg_dump -Fc bytes
    kind: 'database',
    meta: { engine: 'postgresql', format: 'pg_custom', database: 'main' },
  },
  {
    path: 'repos/api.bundle',
    content: await gitBundle(dir), // git bundle create - --all
    kind: 'repo',
    meta: { remote: 'origin', headSha: await gitHeadSha(dir) },
  },
]

const result = await archiveStore.archive({
  projectId,
  parts, // every one of these is archived — a dotenv part would THROW
  // Guards against a silently-empty or partial walk: archive() THROWS rather
  // than returning a verified archive of nothing.
  minParts: 1,
  requiredPaths: ['source/package.json', 'source/package-lock.json', 'database/main.dump'],
  metadata: { reason: 'dormant-30d' },
})

if (!result.verified) {
  // Not an archive. Keep the live project AND the previous artifact; retry later.
  logger.error('project archive unverified — NOT releasing sandbox', {
    projectId,
    verification: result.verification, // downloaded/checksumMatched/manifestParsed/entriesMatched/digestMatched
  })
  return
}

// Verified: re-read from storage, sha256 matched, manifest parsed, parts
// counted, and the unpacked parts digest matched the manifest.
// 1. PERSIST the minted storageId FIRST — without it the artifact is an
//    unreachable orphan (there is no lookup by projectId).
await db.projects.update(projectId, { archiveStorageId: result.storageId })

// 2. Only now is it safe to release the live project…
await releaseSandboxAndDropDatabase(projectId)

// 3. …and only now to delete the OLD archive: every archive() minted a NEW
//    storageId, so the previous artifact was never overwritten and stayed
//    intact as the fallback while the new one was being verified.
if (previousStorageId && previousStorageId !== result.storageId) {
  await archiveStore.remove(previousStorageId) // remove() takes a STORAGE ID
}

// Waking it back up: restore() REQUIRES the persisted storageId, validates the
// payload against the manifest (throws on any mismatch), and returns BYTES —
// the caller re-provisions and routes each part by the kind/meta it recorded.
const storageId = project.archiveStorageId
const summary = await archiveStore.status(storageId) // status() takes a STORAGE ID too
const restored = await archiveStore.restore({ projectId, storageId })

const sandbox = await provisionSandbox(projectId)
for (const part of restored.parts) {
  if (part.kind === 'database') {
    // The archive never interpreted this — meta.format is OUR label.
    await pgRestore(await provisionDatabase(projectId), 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)
  }
}
await writeSecretsFromVault(sandbox, projectId) // dotenv parts are REFUSED, never archived
await runInstallFromLockfile(sandbox) // node_modules was .gitignored, never walked

Providers (5): @molecule/api-project-archive-external-state-d1, @molecule/api-project-archive-external-state-mysql, @molecule/api-project-archive-external-state-postgresql, @molecule/api-project-archive-external-state-sqlite, @molecule/api-project-archive-object-storage

Works with: @molecule/api-bond

Reference

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.ts JSDoc, not this file.

Project archive core interface for molecule.dev.

Defines the ProjectArchiveProvider contract — cold-storage archive, restore, status, and remove for a DORMANT project — along with the generic content channel (ArchivePart), the artifact shape (ArchiveManifest, ArchiveResult, ArchiveVerification, ArchiveStatus), and the accessor (setProvider/getProvider/hasProvider/requireProvider). Interface-only: bond a storage provider package to get an implementation.

The job is exactly this: store some bytes durably, prove they came back, give them back.

Quick Start

import { type ArchivePart, requireProvider, setProvider } from '@molecule/api-project-archive'
import { provider as objectStorageArchive } from '@molecule/api-project-archive-object-storage'

// Wire at startup (equivalently: bond('project-archive', objectStorageArchive)).
setProvider(objectStorageArchive)

// …later, reaping a project that has been dormant for 30 days.
const archiveStore = requireProvider()
const previousStorageId = project.archiveStorageId // whatever we persisted last time

// WHICH files to archive is OUR call, and git already answers it: drop
// everything .gitignore calls disposable, then list what is left. No exclude
// list lives in the archive package.
await exec('git', ['clean', '-Xdf'], { cwd: dir })
const tracked = await exec('git', ['ls-files', '--cached', '--others', '--exclude-standard'], {
  cwd: dir,
})

// ONE generic channel. Source, a database dump and a git bundle are all parts —
// the archive stores their bytes verbatim and never interprets `kind`/`meta`.
// (`git ls-files` does not list history: archive a bundle for that.)
const parts: ArchivePart[] = [
  ...(await Promise.all(
    tracked
      .split('\n')
      .filter(Boolean)
      .map(async (file) => ({
        path: `source/${file}`,
        content: await readFile(join(dir, file)),
        kind: 'source',
      })),
  )),
  {
    path: 'database/main.dump',
    content: await pgDumpCustom(projectId), // pg_dump -Fc bytes
    kind: 'database',
    meta: { engine: 'postgresql', format: 'pg_custom', database: 'main' },
  },
  {
    path: 'repos/api.bundle',
    content: await gitBundle(dir), // git bundle create - --all
    kind: 'repo',
    meta: { remote: 'origin', headSha: await gitHeadSha(dir) },
  },
]

const result = await archiveStore.archive({
  projectId,
  parts, // every one of these is archived — a dotenv part would THROW
  // Guards against a silently-empty or partial walk: archive() THROWS rather
  // than returning a verified archive of nothing.
  minParts: 1,
  requiredPaths: ['source/package.json', 'source/package-lock.json', 'database/main.dump'],
  metadata: { reason: 'dormant-30d' },
})

if (!result.verified) {
  // Not an archive. Keep the live project AND the previous artifact; retry later.
  logger.error('project archive unverified — NOT releasing sandbox', {
    projectId,
    verification: result.verification, // downloaded/checksumMatched/manifestParsed/entriesMatched/digestMatched
  })
  return
}

// Verified: re-read from storage, sha256 matched, manifest parsed, parts
// counted, and the unpacked parts digest matched the manifest.
// 1. PERSIST the minted storageId FIRST — without it the artifact is an
//    unreachable orphan (there is no lookup by projectId).
await db.projects.update(projectId, { archiveStorageId: result.storageId })

// 2. Only now is it safe to release the live project…
await releaseSandboxAndDropDatabase(projectId)

// 3. …and only now to delete the OLD archive: every archive() minted a NEW
//    storageId, so the previous artifact was never overwritten and stayed
//    intact as the fallback while the new one was being verified.
if (previousStorageId && previousStorageId !== result.storageId) {
  await archiveStore.remove(previousStorageId) // remove() takes a STORAGE ID
}

// Waking it back up: restore() REQUIRES the persisted storageId, validates the
// payload against the manifest (throws on any mismatch), and returns BYTES —
// the caller re-provisions and routes each part by the kind/meta it recorded.
const storageId = project.archiveStorageId
const summary = await archiveStore.status(storageId) // status() takes a STORAGE ID too
const restored = await archiveStore.restore({ projectId, storageId })

const sandbox = await provisionSandbox(projectId)
for (const part of restored.parts) {
  if (part.kind === 'database') {
    // The archive never interpreted this — meta.format is OUR label.
    await pgRestore(await provisionDatabase(projectId), 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)
  }
}
await writeSecretsFromVault(sandbox, projectId) // dotenv parts are REFUSED, never archived
await runInstallFromLockfile(sandbox) // node_modules was .gitignored, never walked

Type

core

Installation

npm install @molecule/api-project-archive @molecule/api-bond

API

Interfaces

ArchiveInput

Everything a provider needs to build and upload one archive artifact.

interface ArchiveInput {
  /** The project these bytes belong to; recorded in the manifest. */
  projectId: string

  /**
   * The archive's entire content, as generic parts.
   *
   * Source files, database dumps, git bundles and search indexes all go here —
   * there is no privileged sibling channel for any of them, and no filter: the
   * caller decided which files these are (normally with git/`.gitignore`), and
   * every part handed over is archived.
   */
  parts: ArchivePart[]

  /** Free-form metadata recorded verbatim in the manifest. */
  metadata?: Record<string, string>

  /**
   * Minimum parts required; defaults to `1`.
   *
   * An EMPTY part set THROWS rather than producing a verified empty archive: an
   * empty artifact round-trips and verifies perfectly while proving nothing, so
   * a workspace walk that silently returned `[]` would otherwise hand back
   * `verified: true` and the caller would release a real project. Raise it when
   * the caller knows a floor. Only a provider explicitly configured to allow
   * empty archives may accept `0`.
   */
  minParts?: number

  /**
   * Paths that MUST be present, else throw.
   *
   * The strongest available guard against a partial walk: a source tree missing
   * its lockfile or `package.json` is not restorable, and
   * {@link ArchiveInput.minParts} alone cannot detect that. Compared against
   * {@link ArchivePart.path} exactly.
   */
  requiredPaths?: readonly string[]
}

ArchiveManifest

Self-describing record of what an archive artifact contains.

Stored inside the artifact so a restore can validate the payload without consulting any external database row. parts.sha256 is a digest of the PARTS (path + mode + length + content, sorted by path) AND of everything this manifest SAYS about them — the per-part index and the header fields below — not of the container, so it survives a change to the artifact layout and is what both verification and restore() recompute from the DOWNLOADED artifact. See {@link ArchiveManifest.parts}.

interface ArchiveManifest {
  /** The {@link ARCHIVE_FORMAT_VERSION} the artifact was written with. */
  formatVersion: number

  /** The project the artifact belongs to — the artifact's own owner. */
  projectId: string

  /** ISO-8601 timestamp of when the artifact was built. */
  createdAt: string

  /** Aggregate over every part: how many, how many content bytes, and their digest. */
  parts: {
    /** Number of parts in the artifact. */
    count: number
    /** Total content bytes across every part. */
    bytes: number
    /**
     * Digest over the parts (path + mode + length + content, sorted by path),
     * the per-part {@link ArchiveManifest.entries} index, and the manifest
     * HEADER (`formatVersion`, `projectId`, `createdAt`, `parts.count`,
     * `parts.bytes`, `metadata`) — each section length-framed behind its own
     * marker so no arrangement of one can impersonate another.
     *
     * @remarks
     * Everything the manifest asserts is inside it, because everything the
     * manifest asserts is acted upon: the caller ROUTES on `entries[].kind`,
     * and `status()` reports `projectId`/`createdAt` as FACT. A header outside
     * the digest meant an attacker with bucket write access could rewrite whose
     * project an artifact was, and `restore()` and the read-back verification
     * both still passed.
     *
     * It is UNKEYED and stored beside the bytes it covers, so it detects TAMPER
     * but NOT a wholesale re-forge — see {@link ArchiveVerification.digestMatched}.
     */
    sha256: string
  }

  /**
   * Per-part index: path, bytes, and the caller's kind/meta, verbatim.
   *
   * Recorded exactly as supplied and never interpreted — this is how a restore
   * knows which part is a `pg_custom` dump and which is a git bundle.
   *
   * A row carries these four keys and NOTHING else; a provider refuses a row
   * with an undeclared key rather than passing it on, because a row is an
   * instruction to the restore path and an undigested key would be an
   * unauthenticated one.
   */
  entries: readonly {
    /** The part's POSIX-relative path inside the artifact. */
    path: string
    /** The part's content length in bytes. */
    bytes: number
    /** The caller's opaque {@link ArchivePart.kind} label, if any. */
    kind?: string
    /** The caller's {@link ArchivePart.meta}, recorded verbatim. */
    meta?: Record<string, string>
  }[]

  /** The caller's {@link ArchiveInput.metadata}, recorded verbatim. */
  metadata?: Record<string, string>
}

ArchivePart

A named byte-stream inside an archive.

Nothing is privileged: source files, a database dump, a git bundle and a search index are all parts, distinguished only by the caller's own {@link ArchivePart.path} / {@link ArchivePart.kind} / {@link ArchivePart.meta} labels. The archive stores and returns the bytes verbatim and NEVER interprets them — pairing a pg_custom dump with a non-Postgres target fails when the CALLER restores it, not when the archive stores it.

Adding a second database, a Redis snapshot, or a per-repo git bundle is therefore just more parts. It is never a new field on {@link ArchiveInput}.

interface ArchivePart {
  /**
   * POSIX-relative path inside the artifact, e.g. `'source/src/a.ts'`,
   * `'database/main.dump'`, `'repos/api.bundle'`. No leading slash, no `'..'`.
   *
   * Grouping is a CONVENTION expressed in this path (`source/…`, `database/…`),
   * not a schema — the provider does not parse it. Validated on BOTH sides
   * (archive and restore), on the RAW caller-facing path before any
   * artifact-internal prefixing and again on the stripped path at restore:
   * absolute POSIX (`/x`), a leading backslash, drive-qualified (`C:\x`), any
   * `..` segment, NUL bytes, and empty or `.`-only paths are rejected. Two
   * parts that collide after normalisation (NFC + case-fold) are rejected as
   * duplicates, since they would overwrite each other on restore.
   *
   * @remarks
   * **ONE canonical path model decides what this string's SEGMENTS are, and
   * every rule that reads a path uses it** — path safety, collision detection,
   * and collision detection. A conforming provider normalises a path by folding
   * `'\'` onto `'/'`, collapsing repeated separators, and trimming
   * leading/trailing whitespace from EACH segment, compares segments under
   * Unicode NFC, and applies every rule to those segments. (The MODULE that
   * implements this lives in the bond; this contract only states the rules,
   * because the core is types and data.)
   *
   * **A path whose normalisation would CHANGE it is REJECTED at archive time,
   * never silently rewritten.** The path the caller sent and the path the
   * manifest records must be identical, or the manifest describes something the
   * caller did not send — and the caller is about to delete the original. So
   * `'config\.env'`, `'a//b'`, `'a/b/'`, `'.env '` and `' .env'` all throw
   * rather than being canonicalised into `'config/.env'`, `'a/b'` and `'.env'`.
   *
   * That single model is not pedantry; it is the fix for a measured leak. When
   * path safety folded `'\'` onto `'/'` but the secrets rule split on `'/'`
   * alone, `'config\.env'` was archived and `verified: true` — a live dotenv
   * credential written into plaintext object storage — because `'\'` was a
   * separator to the checks that could not be harmed by it and an ordinary
   * character to the one rule that exists to prevent exactly that.
   */
  path: string

  /** The part's bytes, stored and returned verbatim. */
  content: Uint8Array

  /**
   * Unix mode bits; defaults to `0o644`. Masked to `0o777` on write and on
   * read, so setuid/setgid/sticky (`0o7000`) never survive a round trip.
   */
  mode?: number

  /**
   * Opaque caller label recorded in the manifest, e.g. `'source'` |
   * `'database'` | `'repo'`.
   *
   * Provenance for the caller's own restore logic — the archive never branches
   * on it, and no value is special.
   */
  kind?: string

  /**
   * Free-form metadata recorded verbatim, e.g. `{ format: 'pg_custom' }` or
   * `{ remote: '…', headSha: '…' }`.
   *
   * The archive NEVER interprets these. Record whatever a restore will need to
   * make sense of the bytes (dump format, engine version, remote URL) — but not
   * secrets: the artifact is not encrypted at rest, and the manifest is the
   * most readable thing in it.
   */
  meta?: Record<string, string>
}

ArchiveResult

Result of an archive() call, including its verification verdict.

interface ArchiveResult {
  /** The project that was archived. */
  projectId: string

  /**
   * The storage id the uploads bond MINTED for this artifact. Never derived
   * from `projectId` — the shipped uploads bonds assign a UUID and ignore the
   * supplied filename. PERSIST IT: without it the archive cannot be located,
   * restored, or deleted.
   */
  storageId: string

  /** The manifest that was written into the artifact. */
  manifest: ArchiveManifest

  /** Size of the stored artifact in bytes. */
  bytes: number

  /** TRUE only when the artifact was re-read from storage and fully validated. */
  verified: boolean

  /** Per-step report behind {@link ArchiveResult.verified}. */
  verification: ArchiveVerification
}

ArchiveStatus

Summary of one archive artifact, located by its storage id.

Keyed by storageId, never by project: a project can have any number of artifacts (every archive() mints a new one). projectId is read back out of the stored manifest, so it reports which project the artifact actually belongs to rather than which one the caller assumed.

interface ArchiveStatus {
  /**
   * Read from `manifest.projectId` inside the artifact — not from the lookup
   * key — and only after that manifest was authenticated against the payload.
   */
  projectId: string

  /** The storage id the artifact lives at. */
  storageId: string

  /** When the artifact was built (`manifest.createdAt`). */
  archivedAt: string

  /** Size of the stored artifact in bytes. */
  bytes: number

  /** The artifact's manifest, parsed out of the stored bytes. */
  manifest: ArchiveManifest
}

ArchiveVerification

Per-step outcome of the post-upload read-back check.

Every field must be true for {@link ArchiveResult.verified} to be true; a false field (with error populated) means the artifact is NOT safe to rely on and the live project must be kept.

interface ArchiveVerification {
  /** The artifact was re-read back OUT of storage at the minted storage id. */
  downloaded: boolean

  /** sha256 of the DOWNLOADED artifact bytes equals the pre-upload digest. */
  checksumMatched: boolean

  /** The manifest was parsed out of the DOWNLOADED artifact. */
  manifestParsed: boolean

  /** The downloaded artifact's part count equals `manifest.parts.count`. */
  entriesMatched: boolean

  /**
   * The artifact was UNPACKED and the parts digest recomputed from the
   * downloaded parts matches `manifest.parts.sha256` (and the total part bytes
   * match `manifest.parts.bytes`).
   *
   * This is the only flag that proves the PACKER worked. Without it the other
   * checks compare the artifact to itself — a packer that dropped or corrupted
   * a part's contents still passed every one of them.
   *
   * @remarks
   * **What this flag does NOT prove.** The digest is UNKEYED and is stored
   * inside the very artifact it covers, so it detects TAMPER — any edit that
   * leaves `manifest.parts.sha256` behind, including a relabelled `kind` or a
   * rewritten `projectId` — but it CANNOT detect a WHOLESALE RE-FORGE: an
   * attacker with bucket write access can replace the artifact outright and
   * recompute a perfectly consistent digest over their own content. No unkeyed
   * digest stored beside its data can close that, and this flag must not be
   * read as if it did.
   *
   * The mitigation is a value the attacker cannot rewrite, and it costs one
   * column: **persist `result.manifest.parts.sha256` next to
   * `result.storageId`** when you persist the id, then compare it with
   * `restore().manifest.parts.sha256` (and with `status().manifest.parts.sha256`)
   * before trusting the parts. A re-forge changes the digest; your row still
   * holds the original.
   */
  digestMatched: boolean

  /** Why verification did not complete, when any flag above is false. */
  error?: string
}

ProjectArchiveProvider

The contract every project-archive storage bond implements.

interface ProjectArchiveProvider {
  archive(input: ArchiveInput): Promise<ArchiveResult>
  restore(input: RestoreInput): Promise<RestoreResult>
  status(storageId: string): Promise<ArchiveStatus | null>
  remove(storageId: string): Promise<void>
}

ProjectExternalStateCapture

What one external-state provider produced for a project.

interface ProjectExternalStateCapture {
  /** Artifact parts to pack, for resources whose bytes ride in the artifact. */
  parts: ArchivePart[]
  /** One record per captured resource. Empty when the project has none. */
  records: ProjectExternalStateRecord[]
}

ProjectExternalStateCaptureInput

What an external-state provider is given to capture a project's state.

interface ProjectExternalStateCaptureInput {
  /** The project being archived. */
  projectId: string
  /**
   * Absolute path of a scratch directory the provider may write intermediate
   * files into. Created by the caller and removed after the archive completes,
   * so a provider never has to manage its own temporary space.
   */
  workDir: string
}

ProjectExternalStateProvider

Captures and restores one KIND of state a project owns outside its source tree — a database, an object-storage bucket, a search index, a managed queue.

One provider per kind, registered by name, so support for something new is a new bond rather than an edit to the archiver. A provider that finds nothing of its kind for a project returns empty arrays; that is the normal case, not an error, and it is how a project backed by an in-tree file (a SQLite database committed with the source) needs no provider at all — its data IS source, and whatever archives the source tree already carries it.

The contract that matters

capture must either produce something a later restore can fully rebuild from, or THROW. It must never return a partial capture as if it were whole: the caller's next step is typically to destroy the original.

interface ProjectExternalStateProvider {
  /**
   * Stable identifier for this provider, recorded on every record it produces
   * and used to route those records back to it on restore. Changing it strands
   * every archive that recorded the old value.
   */
  readonly kind: string

  /**
   * Capture everything of this kind that the project owns.
   *
   * @param input - The project and a scratch directory.
   * @returns The parts to pack and the records to write into the index.
   * @throws {Error} If anything of this kind exists but could not be captured
   *   whole — the caller must not proceed to destroy the original.
   */
  capture(input: ProjectExternalStateCaptureInput): Promise<ProjectExternalStateCapture>

  /**
   * Put back what {@link ProjectExternalStateProvider.capture} produced.
   *
   * @param input - The project, this provider's own records, and a part resolver.
   * @throws {Error} If the resource could not be fully restored. The caller
   *   leaves the archive in place so a retry starts from the same bytes.
   */
  restore(input: ProjectExternalStateRestoreInput): Promise<void>
}

ProjectExternalStateRecord

One external resource that was captured for a project.

Deliberately provider-shaped rather than database-shaped: kind says who produced it and therefore who can restore it, id is meaningful only to that provider, and detail carries whatever else that provider needs to put the resource back. Nothing here names an engine, a vendor, or a protocol.

interface ProjectExternalStateRecord {
  /** The {@link ProjectExternalStateProvider.kind} that produced this record. */
  kind: string
  /**
   * Provider-scoped identifier — a database name, a bucket name, an index name.
   * Opaque to everything except the provider that wrote it.
   */
  id: string
  /**
   * Artifact part path holding this resource's bytes, when they travel INSIDE
   * the archive artifact.
   *
   * Omitted when the provider parked the bytes elsewhere (a server-side bucket
   * copy, a managed snapshot), in which case `detail` must carry enough to find
   * them again. Both are legitimate: a database dump is small enough to ride
   * along, a user's uploads can be gigabytes and should not be.
   */
  part?: string
  /** Anything else the provider needs to restore this resource. */
  detail?: Record<string, string | number | boolean | null>
}

ProjectExternalStateRestoreInput

What an external-state provider is given to put a project's state back.

interface ProjectExternalStateRestoreInput {
  /** The project being restored. */
  projectId: string
  /** Only the records this provider produced, in the order it produced them. */
  records: readonly ProjectExternalStateRecord[]
  /**
   * Resolve an artifact part path (as recorded in
   * {@link ProjectExternalStateRecord.part}) to an absolute host path where the
   * caller has already written those bytes.
   *
   * @param artifactPath - The recorded part path.
   * @returns The absolute host path of the extracted part.
   */
  partPath: (artifactPath: string) => string
}

RestoreInput

Selector for a restore: the storage id archive() returned, plus the project the bytes are being restored INTO.

storageId is REQUIRED — there is no derivable key. projectId is the destination label echoed onto {@link RestoreResult}; the archive's own project id is in manifest.projectId, so restoring one project's archive into a different project is an explicit, visible act.

interface RestoreInput {
  /** The project the bytes are being restored INTO. */
  projectId: string

  /** The storage id `archive()` minted and the caller persisted. */
  storageId: string
}

RestoreResult

The archived bytes, handed back to the caller.

Restoring does NOT recreate a sandbox, a database, or a git remote — the caller re-provisions those and applies these parts, routing each one by the kind/meta it recorded at archive time.

interface RestoreResult {
  /** The project the parts were restored into (echoed from {@link RestoreInput}). */
  projectId: string

  /** The artifact's manifest, validated against the payload before returning. */
  manifest: ArchiveManifest

  /** Every part in the artifact, paths and modes preserved. */
  parts: ArchivePart[]
}

Functions

getExternalStateProvider(kind)

One registered external-state provider by kind, or null.

A restore that finds null for a kind it HAS records for must fail loudly rather than skip: the records exist because that state was captured, and silently not restoring it hands the user a project missing its data.

function getExternalStateProvider(kind: string): ProjectExternalStateProvider | null
  • kind — The {@link ProjectExternalStateProvider.kind} to look up.

Returns: The provider, or null.

getExternalStateProviders()

Every registered external-state provider, keyed by kind.

An archive captures from ALL of them; a restore routes each record back to the one whose kind matches. An empty map is legitimate — a deployment whose projects own nothing outside their source tree needs no providers.

function getExternalStateProviders(): Map<string, ProjectExternalStateProvider>

Returns: The registered providers, keyed by {@link ProjectExternalStateProvider.kind}.

getProvider()

Get the active project archive provider, or null if none is configured.

function getProvider(): ProjectArchiveProvider | null

Returns: The current provider or null.

hasExternalStateProviders()

Whether any external-state provider is registered.

function hasExternalStateProviders(): boolean

Returns: True when at least one provider is bonded.

hasProvider()

Check whether a project archive provider is configured.

function hasProvider(): boolean

Returns: True if a provider has been set.

requireProvider()

Get the active project archive provider, throwing if none is configured.

function requireProvider(): ProjectArchiveProvider

Returns: The current provider.

setExternalStateProvider(provider)

Register an external-state provider under its own {@link ProjectExternalStateProvider.kind}.

Registering under the provider's own kind rather than a caller-chosen name is what makes restore routing work: records carry kind, and that is the key they are looked up by. A provider bonded under a different name captures state that nothing can restore.

function setExternalStateProvider(provider: ProjectExternalStateProvider): void
  • provider — The provider to register.

setProvider(provider)

Set the active project archive provider.

function setProvider(provider: ProjectArchiveProvider): void
  • provider — The project archive provider to register.

Constants

ARCHIVE_FORMAT_VERSION

Artifact layout version recorded in every {@link ArchiveManifest}.

Bump it when the artifact layout changes incompatibly — a provider must REFUSE to read an artifact whose formatVersion is higher than the one it understands, rather than silently misreading it.

const ARCHIVE_FORMAT_VERSION: 3

Available Providers

ProviderPackage
Cloudflare D1 Database@molecule/api-project-archive-external-state-d1
MySQL / MariaDB Database@molecule/api-project-archive-external-state-mysql
PostgreSQL Database@molecule/api-project-archive-external-state-postgresql
SQLite Database File@molecule/api-project-archive-external-state-sqlite
Project Archive@molecule/api-project-archive-object-storage

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-bond ^1.0.1

Runtime Dependencies

  • @molecule/api-bond

  • Wire it at startup with setProvider(...) — or the equivalent bond('project-archive', provider). This core routes through the shared @molecule/api-bond registry, so either call registers the same provider and validateBonds() reports it as missing when unwired.

  • 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, .gitignore already declares what is disposable, git clean -Xdf removes it, and git ls-files --cached --others --exclude-standard lists what survives — twenty years of solved semantics that users already write. There is no exclude list, no policy object, no per-ecosystem preset and no filter helper in this package: every part you hand archive() is archived. The layer that used to do this shipped two silent-data-loss bugs — a directory exclude applied to filenames deleted src/build/compiler.ts, src/tmp.ts, src/build.rs and src/dist.config.js with no signal, and a separator disagreement let config\.env reach plaintext storage. It is gone.

  • ONE exception, and it is a security rule rather than a filter: a part whose path has ANY segment equal to .env, or starting with .env. (case-INSENSITIVE), makes archive() THROW. Not configurable, no opt-out, no options object. 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; whether your .gitignore happens to exclude .env is your choice, and a choice is not a sound basis for a credential outcome. Both widenings are load-bearing: a basename-only compare archived .env/prod.key and config/.env/staging, and a case-sensitive one archived .ENV, .Env and .eNv.production. Keep secrets in the platform's encrypted vault and re-inject them on restore. (The same applies to metadata/meta, which the manifest carries in the clear, and to a .git/config remote URL with an embedded user:token@host — scrub those before archiving.)

  • NOTHING IS PRIVILEGED: an archive is a list of parts, and that is the whole content channel. A source file, a pg_dump, a Redis snapshot, a Meilisearch index and a git bundle are all ArchiveParts — each just a path + content (+ optional mode, kind, meta). There is no files field, no databaseDump field, and no database format enum, so a SECOND database or any new content type is one more part, never a new field. Group parts with a path convention you choose (source/…, database/…, repos/…) — the provider does not parse it.

  • The archive NEVER interprets kind or meta. They are opaque labels recorded verbatim into the manifest for the CALLER's restore logic. A provider must not branch on them, must not decode a part's bytes, and must treat every part identically. Consequence: a { format: 'pg_custom' } dump restored into a non-Postgres engine fails when YOU run pg_restore, not at archive time — record enough in meta (dump format, engine version, git remote and head sha) that a restore can route each part correctly.

  • verified: true is the ONLY signal that may precede releasing the live project. Nothing else counts — not "it didn't throw", not a successful upload, not a non-empty storageId. verified is true only after the provider re-read the artifact back OUT of storage at the minted id, re-hashed the downloaded bytes against the pre-upload sha256, parsed the manifest from those downloaded bytes, matched the part count, AND unpacked the artifact to recompute the parts digest and byte total against manifest.parts.sha256/bytes (verification.digestMatched — the one flag that proves the packer actually preserved the bytes). A verification failure does NOT throw: it comes back as verified: false + verification.error, so code that ignores the return value and reaps the sandbox anyway destroys the only copy. Check the flag.

  • archive() THROWS on an empty part set — it will never hand back a verified empty archive. A workspace walk that silently returned [] would otherwise verify perfectly (an empty artifact round-trips fine) and the caller would delete a real project. ArchiveInput.minParts (default 1) is the floor, and ArchiveInput.requiredPaths is the stronger guard — list the parts a restore cannot do without (source/package.json, the lockfile, database/main.dump) and a partial walk throws instead of shipping an unrestorable artifact. Unsafe or duplicate paths, a dotenv part, an exceeded size cap, and a failed upload throw too; those are never archives, so there is nothing for the caller to weigh. (A provider caps the stored artifact BEFORE decompressing anything it downloads, caps the decompressed payload separately as the decompression-bomb guard, and never embeds archive bytes in an error message.)

  • Every archive() mints a NEW storageId; re-archiving NEVER overwrites the previous artifact. The id comes from the uploads bond, which assigns its own (the shipped bonds mint a UUID and ignore the supplied filename) — it is never derived from projectId, so there is no key to collide on. Consequence: remove the OLD archive only AFTER the NEW one comes back verified: true. Deleting first, or overwriting in place, is how a good artifact gets destroyed by a bad replacement.

  • The caller MUST persist result.storageId (e.g. onto the project's database row). Without it the archive cannot be located, restored, or deleted — it is an orphan object burning storage. There is NO lookup by project: restore() REQUIRES storageId, and status(storageId) / remove(storageId) take the storage id, NOT a project id. projectId on RestoreInput is only the destination label; the artifact's own owner is manifest.projectId.

  • Archiving is for DORMANT projects. Do NOT archive a project a user is actively editing — the artifact is a point-in-time snapshot, and writes that land after the parts are read are silently lost. Pick projects that have been idle long enough that a snapshot is the whole truth.

  • restore() VALIDATES the payload against the manifest and throws on mismatch. It re-checks the part count against manifest.parts.count, the recomputed parts digest against manifest.parts.sha256, and the total bytes against manifest.parts.bytes. A partial or tampered artifact fails loudly — it never yields half a project. Do not catch that error and write whatever came back anyway.

  • manifest.parts.sha256 covers EVERYTHING the manifest asserts — the part bytes, the per-part index you route on, and the header (formatVersion, projectId, createdAt, parts.count, parts.bytes, metadata) — and a manifest carrying any UNDECLARED key is refused outright. Anything outside the digest is an unauthenticated instruction to your restore path. But the digest is UNKEYED and lives inside the artifact, so it cannot detect a WHOLESALE RE-FORGE — an attacker with bucket write access replaces the artifact and recomputes a consistent digest. If that is in your threat model, persist result.manifest.parts.sha256 beside result.storageId and compare it on restore; nothing inside the artifact can do it for you.

  • restore() returns bytes; it does NOT recreate a sandbox, a database, or a git remote. It hands back parts — the CALLER re-provisions, routes each part by the kind/meta it recorded, writes the source, applies the dump, unbundles the repo, and re-injects secrets from the vault. Nothing is running when restore() resolves.

  • ArchivePart.path is POSIX-relative and CANONICAL — no leading slash, no .. segments, no drive letter, no backslash ANYWHERE, no NUL bytes, no repeated or trailing separator, no whitespace-padded segment, not empty or .-only, and no two parts that collide after normalisation. Both sides enforce this: on the caller's RAW path before any artifact-internal prefixing, and again on the stripped path at restore. A restore that wrote an absolute or escaping path would write outside the new workspace. A path that normalisation would CHANGE is REJECTED rather than rewritten, so the path you sent is the path the manifest records — and so ONE model decides what a segment is for path safety, the dotenv refusal and collision detection alike. When those disagreed, config\.env archived and verified: a live credential in plaintext object storage. Modes are masked to 0o777, so setuid/setgid/sticky bits never survive a round trip.