@molecule/api-project-archive-external-state-pglite

Provider bond · project-archive-external-state · API (Node) · v1.0.1 · Apache-2.0

Back up and restore a project's PGlite database as part of a project archive — captures it with PGlite's own dumpDataDir, proves the dump loads back with the same row counts before the caller destroys anything, and restores it with loadDataDir after a checksum check. In-memory databases are declared, never inferred.

npm install @molecule/api-project-archive-external-state-pglite

npm · Source on GitHub · Implements @molecule/api-project-archive

How it works

@molecule/api-project-archive-external-state-pglite is a provider bond on the API (Node) side: it implements the project-archive-external-state 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 { setExternalStateProvider } from '@molecule/api-project-archive'
import { createPgliteExternalStateProvider } from '@molecule/api-project-archive-external-state-pglite'

setExternalStateProvider(
  createPgliteExternalStateProvider({
    // The same dataDir each project's pool was created with.
    dataDirs: (projectId) => [`/var/lib/app/${projectId}/pglite`],
  }),
)

Works with: @molecule/api-database-pglite, @molecule/api-project-archive

Reference

Capture and restore a project's PGlite databases for @molecule/api-project-archive.

It dumps each data directory with PGlite's own dumpDataDir and loads it back with the loadDataDir option, so a restore reproduces the database exactly — the same files, not a replay of SQL.

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.

Quick Start

import { setExternalStateProvider } from '@molecule/api-project-archive'
import { createPgliteExternalStateProvider } from '@molecule/api-project-archive-external-state-pglite'

setExternalStateProvider(
  createPgliteExternalStateProvider({
    // The same dataDir each project's pool was created with.
    dataDirs: (projectId) => [`/var/lib/app/${projectId}/pglite`],
  }),
)
// A project whose database lives in memory: declare it. The archive then
// records it as having nothing durable instead of silently leaving it out.
createPgliteExternalStateProvider({
  dataDirs: (projectId) => (isEphemeral(projectId) ? ['memory://'] : []),
})

Type

provider

Installation

npm install @molecule/api-project-archive-external-state-pglite @molecule/api-database-pglite @molecule/api-project-archive

API

Interfaces

DataDirDump

What one dump produced.

interface DataDirDump {
  /** The gzip-compressed tar PGlite's `dumpDataDir('gzip')` returned. */
  bytes: Uint8Array
  /** Row counts read under the same lock, immediately before the dump. */
  tables: TableCounts
  /** `server_version` of the instance that wrote the dump. */
  postgresVersion: string
}

LoadOutcome

Outcome of loading a tar into a folder.

interface LoadOutcome {
  /** Row counts of the loaded database. */
  tables: TableCounts
}

PgliteExternalStateConfig

How this deployment finds a project's PGlite databases.

interface PgliteExternalStateConfig {
  /**
   * The data directory of every PGlite database belonging to `projectId` —
   * the same strings handed to `createPool({ dataDir })` in
   * `@molecule/api-database-pglite`.
   *
   * **This is a DECLARATION, not a search.** An empty array means the project
   * genuinely owns none — and it is the ONLY way to say that. Each entry is
   * either:
   *
   * - a filesystem path (relative paths resolve against the working directory,
   *   exactly as the database bond resolves them). It must already hold a
   *   PGlite data directory at capture time; a missing or empty folder is an
   *   ERROR, never an absence, because a path template one directory off would
   *   otherwise capture nothing and let the caller destroy the only copy.
   * - `'memory://'` — the project's database lives in memory and has nothing
   *   durable to capture. Capture records that explicitly (a record with no
   *   part) so the archive says so, instead of the database silently not
   *   appearing.
   *
   * Any other scheme (`idb://…`) is refused: it cannot be reached from a server.
   *
   * @param projectId - The project being archived or restored.
   * @returns Its data directories; `[]` when it owns none.
   */
  dataDirs: (projectId: string) => readonly string[] | Promise<readonly string[]>

  /**
   * How long (ms) to wait for the database's single connection before giving
   * up, when the app's own pool is busy with it. Capture takes the same lock
   * the app's pool uses, so a dump never runs in the middle of a transaction.
   * Defaults to 30 000.
   */
  acquireTimeoutMillis?: number

  /**
   * Largest uncompressed data directory (bytes) a restore will unpack. Guards
   * against a decompression bomb in a tampered archive. Defaults to 2 GiB.
   */
  maxDataDirBytes?: number
}

Types

TableCounts

Row count per user table, keyed "schema"."table", plus the current value of every user sequence, keyed sequence "schema"."name" — all as decimal strings.

type TableCounts = Record<string, string>

Classes

JoinedInstanceError

Raised when a restore target turned out to be open in another pool of this process — the load was ignored and the folder belongs to someone else, so it must not be cleaned up.

class JoinedInstanceError extends Error {
  /**
   * Build the error for a folder another pool holds.
   *
   * @param dataDir - The folder another pool holds.
   */
  constructor(dataDir: string) {
    super(
      `the PGlite data directory ${dataDir} is already open in this process, so the archived ` +
        `database cannot be loaded into it. End the pools using it and restore again.`,
    )
    this.name = 'JoinedInstanceError'
  }
}

Functions

sha256Hex(bytes)

Hex SHA-256 of some bytes.

function sha256Hex(bytes: Uint8Array<ArrayBufferLike>): string
  • bytes — The bytes to hash.

Returns: The lowercase hex digest.

countTables(instance)

Count the rows of every user table and read every user sequence (everything outside the system schemas), in a stable order.

function countTables(instance: PgliteInstance): Promise<TableCounts>
  • instance — An open instance whose connection the caller holds.

Returns: Row counts keyed by quoted schema.table.

canonicalCounts(counts)

Canonical string form of a set of table counts, for comparison.

function canonicalCounts(counts: TableCounts): string
  • counts — The counts.

Returns: A string that is equal for equal count sets regardless of key order.

dumpDataDir(dataDir, timeoutMillis)

Dump one data directory through the app's shared instance.

Joins (or opens) the instance for dataDir in the database bond's registry, waits for its single connection, and reads the row counts and the dump while holding it — so nothing commits between the two and no transaction is open during the dump.

function dumpDataDir(dataDir: string, timeoutMillis: number): Promise<DataDirDump>
  • dataDir — Absolute path of an existing PGlite data directory.
  • timeoutMillis — How long to wait for the connection.

Returns: The dump and what it should contain.

inflateDump(gz, maxBytes)

Decompress a dump into the tar PGlite's loadDataDir takes.

Done here with node's zlib rather than by PGlite: PGlite 0.5.8 inflates a gzip dump on a web stream whose failure is not tied to any promise, so a truncated gzip crashes the whole process instead of rejecting. Handing it a plain tar that zlib has already validated avoids that path entirely.

function inflateDump(
  gz: Uint8Array<ArrayBufferLike>,
  maxBytes: number,
): Promise<Uint8Array<ArrayBuffer>>
  • gz — The gzip bytes.
  • maxBytes — Largest uncompressed size accepted.

Returns: The uncompressed tar.

countLoadedDump(tar)

Load a tar into a fresh in-memory instance and count its rows — proof that a dump loads and holds what was counted when it was taken.

function countLoadedDump(tar: Uint8Array<ArrayBuffer>): Promise<TableCounts>
  • tar — The uncompressed data-directory tar.

Returns: The row counts of the loaded database.

loadDataDir(dataDir, tar, timeoutMillis)

Load a tar into an empty folder, through the database bond's registry, and count what landed.

function loadDataDir(
  dataDir: string,
  tar: Uint8Array<ArrayBuffer>,
  timeoutMillis: number,
): Promise<LoadOutcome>
  • dataDir — Absolute path of the (empty or absent) target folder.
  • tar — The uncompressed data-directory tar.
  • timeoutMillis — How long to wait for the connection.

Returns: The counts of the loaded database.

createPgliteExternalStateProvider(config)

Create the provider.

function createPgliteExternalStateProvider(
  config: PgliteExternalStateConfig,
): ProjectExternalStateProvider
  • config — How to find a project's PGlite databases.

Returns: A provider ready to bond with setExternalStateProvider.

Constants

KIND

Recorded on every record this provider produces; routes restores back here.

const KIND: 'pglite'

DUMP_FORMAT

Recorded in each record so a future format change can be told apart.

const DUMP_FORMAT: 'pglite-datadir-tar-gzip'

Core Interface

Implements @molecule/api-project-archive interface.

Bond Wiring

Setup function to register this provider with the bond system:

import { bond } from '@molecule/api-bond'
import { provider } from '@molecule/api-project-archive-external-state-pglite'

export function setupProjectArchiveExternalStatePglite(): void {
  bond('project-archive-external-state', 'pglite', provider)
}

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-database-pglite ^1.0.0
  • @molecule/api-project-archive ^1.0.1

Runtime Dependencies

  • @molecule/api-database-pglite
  • @molecule/api-project-archive

Bond the SAME copy of @molecule/api-database-pglite your app uses (it is a peer dependency for that reason). Capture and restore go through that package's instance registry: when the app's pool has a folder open, capture JOINS that instance and waits for its single connection, so a dump never runs mid-transaction and a second PGlite is never opened on the same folder (two instances on one folder overwrite each other's files). Never point another PROCESS at a folder while it is being captured or restored — PGlite does not lock its data directory.

Verified before the caller may destroy anything. Capture counts every table's rows under the connection lock, dumps, then loads the dump into a scratch in-memory instance and checks the counts match. The record carries the dump's SHA-256 and byte size; restore refuses bytes that do not match them, refuses to unpack more than maxDataDirBytes (default 2 GiB), and checks the restored database's row counts against the recorded ones.

Restore only into an empty or absent folder. A folder that already holds anything is refused, so a live database is never overwritten. If a restore fails, the folder is put back the way it was found (removed, or emptied). End the app's pools on that folder before restoring.

Absence is DECLARED, never inferred. Only an empty dataDirs result says a project owns no database. A declared folder with no PGlite data directory in it (no PG_VERSION) is an ERROR — opening PGlite there would create a fresh empty database and capture that. 'memory://' is the explicit way to say "nothing durable"; idb:// and other schemes are refused.

If the data directory lives inside the project's source tree and is committed, you do not need this package — whatever archives the source tree already carries it.