@molecule/api-database-pglite

Provider bond · database · API (Node) · v1.2.2 · Apache-2.0

PGlite database provider for molecule.dev — real Postgres compiled to WebAssembly, in-process: in memory, on disk, or in the browser (IndexedDB). The dev and browser profile; production swaps the postgresql bond in with the same app code. One connection at a time.

npm install @molecule/api-database-pglite

npm · Source on GitHub · Implements @molecule/api-database

How it works

@molecule/api-database-pglite is a provider bond on the API (Node) side: it implements the database core interface (@molecule/api-database) 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 { setPool, setStore } from '@molecule/api-database'
import { createMigrator, pool, store } from '@molecule/api-database-pglite'

// Data directory comes from PGLITE_DATA_DIR (default ./data/pglite under Node).
setPool(pool)
setStore(store)

// Same migrations folder the postgresql bond reads; runs on the same pool.
await createMigrator(new URL('../migrations', import.meta.url).pathname)()

Works with: @molecule/api-bond, @molecule/api-database, @molecule/api-secrets

Secrets: PGLITE_DATA_DIR (optional)

Live demos

Running apps built with Molecule that install this package — open the app or study the template.

Reference

PGlite database provider for molecule.dev — real Postgres (compiled to WebAssembly) running inside your own process, with no database server.

This is the development / in-browser profile of the database: the same Postgres SQL, the same $1 placeholders, the same error codes and the same DataStore SQL as @molecule/api-database-postgresql, so app code written against @molecule/api-database runs unchanged on both. Develop on PGlite; in production bond the postgresql provider instead — swap the import, keep the app code and the migrations.

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 { setPool, setStore } from '@molecule/api-database'
import { createMigrator, pool, store } from '@molecule/api-database-pglite'

// Data directory comes from PGLITE_DATA_DIR (default ./data/pglite under Node).
setPool(pool)
setStore(store)

// Same migrations folder the postgresql bond reads; runs on the same pool.
await createMigrator(new URL('../migrations', import.meta.url).pathname)()
// Explicit pools: in memory (tests), on disk, or in IndexedDB.
import { applyMigrations, createPool, createStore } from '@molecule/api-database-pglite'

const testPool = createPool({ dataDir: 'memory://' })
const diskPool = createPool({ dataDir: './data/pglite' })
const browserPool = createPool({ dataDir: 'idb://my-app' })

// Without a filesystem, pass the SQL in (e.g. bundled with Vite's ?raw).
await applyMigrations(browserPool, [{ name: '0001_init.sql', sql: initSql }])
const store = createStore(browserPool)

// Production: the same app code against the real server.
// import { pool, store } from '@molecule/api-database-postgresql'

Type

provider

Installation

npm install @molecule/api-database-pglite @electric-sql/pglite @molecule/api-bond @molecule/api-database @molecule/api-secrets

API

Interfaces

PgliteInstance

One opened database: the PGlite instance, the lock over its single connection, and how many pools currently use it.

interface PgliteInstance {
  /** The PGlite instance. */
  db: PGlite
  /** FIFO lock over the instance's one connection. */
  lock: ConnectionLock
  /** The resolved data directory (absolute path for Node filesystem paths). */
  dataDir: string
  /** Pools currently using this instance. */
  refs: number
  /** Registry key for shared (persistent) instances; `null` for in-memory ones. */
  key: string | null
}

PgliteMigration

One migration: a name (for ordering and logs) and its SQL.

interface PgliteMigration {
  /** File name, e.g. `0001_init.sql`. Migrations run sorted by name. */
  name: string
  /** One or more SQL statements. */
  sql: string
}

PgliteConfig

Configuration for a PGlite-backed pool.

interface PgliteConfig {
  /**
   * Where the database lives:
   *
   * - `'memory://'` — in memory; gone when the pool ends or the process exits.
   * - a filesystem path (Node only), e.g. `'./data/pglite'` — a Postgres data
   *   directory; parent directories are created for you.
   * - `'idb://<name>'` — the browser's IndexedDB (needs an `indexedDB` global).
   *
   * Defaults to the `PGLITE_DATA_DIR` env var, then `'./data/pglite'` when a
   * Node runtime is present, else `'memory://'`.
   */
  dataDir?: string

  /**
   * How long (ms) a query, `connect()` or `transaction()` waits for the single
   * connection before failing with an error that says who is holding it.
   * Defaults to 10 000 (the postgresql bond's `connectionTimeoutMillis`).
   */
  acquireTimeoutMillis?: number

  /**
   * Return `int8`/`bigint` values (`COUNT(*)`, `SUM(int)`, `bigint` columns) as
   * strings, exactly like node-postgres. PGlite's own default is a JS number,
   * or a `BigInt` above 2^53 — which `JSON.stringify` cannot serialize.
   * Defaults to `true` so app code behaves the same here and on the
   * postgresql bond.
   */
  int8AsString?: boolean

  /**
   * Extra options handed to the `PGlite` constructor as-is (extensions,
   * `relaxedDurability`, custom `parsers`, `debug`, …). `dataDir` is set by
   * the field above; a `parsers` entry here wins over `int8AsString`.
   */
  pgliteOptions?: Omit<PGliteOptions, 'dataDir'>
}

PglitePool

A DatabasePool backed by PGlite, plus the two things only this bond has.

interface PglitePool extends DatabasePool {
  /**
   * Begins a transaction on the single connection (always implemented here).
   *
   * @returns The open transaction; end it with `commit()` or `rollback()`.
   */
  transaction(): Promise<DatabaseTransaction>

  /**
   * Runs SQL that may hold several statements (a migration file) through
   * Postgres's simple-query protocol. Takes no parameters.
   *
   * @param sql - One or more SQL statements separated by `;`.
   */
  exec(sql: string): Promise<void>

  /**
   * The data directory this pool resolved to (`'memory://'` for in-memory).
   * Known only after first use; `undefined` before that.
   */
  readonly dataDir: string | undefined
}

ProcessEnv

Environment variables consumed by the PGlite database provider.

interface ProcessEnv {
  PGLITE_DATA_DIR?: string
}

Classes

ConnectionLock

Serializes every use of ONE PGlite instance.

PGlite is a single Postgres backend: BEGIN/COMMIT/ROLLBACK apply to the same session every other query uses. Without this lock an unrelated pool.query() issued while a transaction is open runs INSIDE it and is thrown away by that transaction's rollback (verified against PGlite 0.5.8). Plain queries hold the lock for one statement; connect() and transaction() hold it until they are released.

Waiting is bounded: a waiter that cannot get the lock within its timeout is removed from the queue and rejected with an error naming the likely cause (code holding a connection while calling the pool), instead of hanging forever.

class ConnectionLock {
  private locked = false
  private holder: string | null = null
  private waiters: Array<{ grant: () => void }> = []

  /**
   * Number of callers currently waiting for the lock.
   *
   * @returns The queue length.
   */
  get waiting(): number {
    return this.waiters.length
  }

  /**
   * Whether the lock is currently held.
   *
   * @returns `true` when held.
   */
  get isLocked(): boolean {
    return this.locked
  }

  /**
   * Waits for the lock in FIFO order. The returned release function MUST be
   * called exactly once; extra calls are ignored.
   *
   * @param purpose - What the caller will do with the lock (`'query'`,
   *   `'connect()'`, `'transaction()'`), used in the timeout message.
   * @param timeoutMillis - Maximum wait; `0` or less waits forever.
   * @returns A release function.
   */
  acquire(purpose: string, timeoutMillis: number): Promise<() => void> {
    let released = false
    const release = (): void => {
      if (released) return
      released = true
      this.holder = null
      const next = this.waiters.shift()
      if (next) {
        next.grant()
      } else {
        this.locked = false
      }
    }

    if (!this.locked) {
      this.locked = true
      this.holder = purpose
      return Promise.resolve(release)
    }

    return new Promise<() => void>((resolve, reject) => {
      let timer: ReturnType<typeof setTimeout> | undefined
      const waiter = {
        grant: (): void => {
          if (timer !== undefined) clearTimeout(timer)
          this.locked = true
          this.holder = purpose
          resolve(release)
        },
      }
      this.waiters.push(waiter)
      if (timeoutMillis > 0) {
        timer = setTimeout(() => {
          const index = this.waiters.indexOf(waiter)
          if (index === -1) return
          this.waiters.splice(index, 1)
          reject(
            new Error(
              `PGlite: a ${purpose} waited ${timeoutMillis} ms for the database's single ` +
                `connection, which is held by an open ${this.holder ?? 'operation'}. PGlite has ` +
                `exactly one connection: release() a connect()ed connection and commit()/rollback() ` +
                `a transaction before calling the pool or the store again, and run every statement ` +
                `of a transaction on its own conn.query().`,
            ),
          )
        }, timeoutMillis)
      }
    })
  }
}

Functions

getPoolInstance()

Returns the default pool, created on first call from PGLITE_DATA_DIR (read then, not at import, so env loading can run first).

function getPoolInstance(): PglitePool

Returns: The shared default pool.

chooseDataDir(config)

Picks the data directory from config, then PGLITE_DATA_DIR, then the runtime default.

function chooseDataDir(config?: PgliteConfig): string
  • config — Pool configuration.

Returns: The data directory as given (not yet resolved to an absolute path).

acquireInstance(config)

Opens (or joins) the instance for a pool. In-memory pools always get their own instance; pools on the same persistent directory share one.

function acquireInstance(config?: PgliteConfig): Promise<PgliteInstance>
  • config — Pool configuration.

Returns: The instance, with a reference taken for the caller.

releaseInstance(instance)

Drops a pool's reference; closes the instance when no pool uses it.

function releaseInstance(instance: PgliteInstance): Promise<void>
  • instance — The instance to release.

isIdempotencyError(err)

Whether an error is the "already exists" kind an IF NOT EXISTS migration is expected to hit on a re-run. Matches the SQLSTATE code first, then the message text.

function isIdempotencyError(err: unknown): boolean
  • err — The error thrown while applying a migration.

Returns: true if it is safe to warn and continue.

applyMigrations(pool, migrations)

Applies migrations to a pool, in name order. Works in any runtime (the SQL is passed in, nothing is read from disk) — use this in a browser, with the SQL bundled by your build tool.

function applyMigrations(pool: PglitePool, migrations: PgliteMigration[]): Promise<void>
  • pool — The PGlite pool to migrate.
  • migrations — The migrations to apply.

Returns: Resolves when every migration has been applied.

createMigrator(migrationsDir, pool)

Returns a runMigrations() bound to a migrations directory (Node only).

function createMigrator(migrationsDir: string, pool?: PglitePool): () => Promise<void>
  • migrationsDir — Absolute path to the directory of ordered *.sql files, e.g. join(new URL('.', import.meta.url).pathname, '../../migrations') from the app's scripts/migrate.ts.
  • pool — The pool to migrate. Defaults to the package's default pool (the one pool/store use), so migrations land in the database the app reads.

Returns: A no-arg runMigrations() that applies every *.sql file in lexical order. A missing directory counts as "no migrations".

createPool(config)

Creates a DatabasePool backed by PGlite.

The database opens on first use, not here, so creating a pool is free and synchronous. See PgliteConfig for where data is kept.

function createPool(config?: PgliteConfig): PglitePool
  • config — Data directory, wait timeout and PGlite options.

Returns: A pool over one PGlite connection.

createStore(pool)

Creates a DataStore backed by a PGlite (or any Postgres) DatabasePool.

function createStore(pool: DatabasePool): DataStore
  • pool — The DatabasePool to use for queries.

Returns: A DataStore that translates CRUD operations to SQL queries.

Constants

pool

The default PGlite pool. Bond it with setPool(pool).

const pool: PglitePool

store

The default DataStore, backed by the default pool. Bond it with setStore(store).

const store: DataStore

MEMORY_DATA_DIR

The in-memory data directory.

const MEMORY_DATA_DIR: 'memory://'

DEFAULT_NODE_DATA_DIR

Default on-disk data directory under a Node runtime.

const DEFAULT_NODE_DATA_DIR: './data/pglite'

MIGRATION_LEDGER

The table that records which migration files have run.

const MIGRATION_LEDGER: '_molecule_migrations'

databasePgliteSecretDefinitions

Secret definitions read by the PGlite database bond.

const databasePgliteSecretDefinitions: SecretDefinition[]

Core Interface

Implements @molecule/api-database interface.

Bond Wiring

Setup function to register this provider with the core interface:

import { setPool, setStore } from '@molecule/api-database'
import { pool, store } from '@molecule/api-database-pglite'

export function setupDatabasePglite(): void {
  setPool(pool)
  setStore(store)
}

Injection Notes

Requirements

Peer dependencies:

  • @molecule/api-bond ^1.0.1
  • @molecule/api-database ^1.1.0
  • @molecule/api-secrets ^1.0.1

Environment Variables

  • PGLITE_DATA_DIR (optional) — PGlite data directory — default: ./data/pglite
    • Setup: A filesystem path (Node), idb://<name> (browser IndexedDB), or memory:// (in memory, lost on exit).
    • Example: ./data/pglite

Runtime Dependencies

  • @electric-sql/pglite

  • @molecule/api-bond

  • @molecule/api-database

  • @molecule/api-secrets

  • Exactly ONE connection. PGlite is a single Postgres backend, so the pool serializes everything: plain query() calls queue in order; connect() and transaction() hold the connection until release() / commit() / rollback(). Never call pool.query() or a store helper (create, findMany, …) while YOUR code holds a connection or an open transaction — on Postgres that runs on a second connection, here it waits for the one you hold. After acquireTimeoutMillis (default 10 s) it fails with an error saying a connection is held, instead of hanging. Inside a transaction run every statement on that transaction's own query(), and always release in finally (the same rule the postgresql bond needs).

  • All-or-nothing writes: withTransaction(async () => { … }) from @molecule/api-database. Every call inside it — the CRUD helpers (create, findMany, updateById, …) and query() — joins the one transaction; it commits when the callback resolves, rolls back when it throws.

  • Where data lives (dataDir, else PGLITE_DATA_DIR): a filesystem path (Node; default ./data/pglite, parent folders created), idb://<name> (browser IndexedDB), or memory:// (gone when the process ends; the default when no Node runtime is present). Pools on the same directory in one process share one instance; never point two PROCESSES at one data directory — PGlite does not lock it and they will corrupt each other. Different directories (and every memory:// pool) are fully separate databases.

  • Returned types match node-postgres: numeric comes back as a string ('12.50'), int8/bigint including COUNT(*) as a string (set int8AsString: false for PGlite's number/BigInt), json/jsonb parsed, boolean as boolean, timestamptz as Date. Known differences: bytea is a Uint8Array (pg: Buffer), interval is a string (pg: an object), and date is midnight UTC (pg: local midnight).

  • Errors are Postgres errors with the same fields as pg's DatabaseError — code ('23505' unique violation, '23503' foreign key, '42P01' missing table), constraint, table, detail — so error handling written for the postgresql bond works unchanged.

  • query(text) with NO parameters may hold several statements (like node-postgres) and returns the LAST statement's result; with parameters it must be one statement.

  • A transaction's bare release() (without commit/rollback) ROLLS BACK before freeing the connection, so the next caller never inherits an open transaction.

  • Browser use: every @molecule/api-* package — this one and @molecule/api-database included — refuses to load in a plain browser page (no Node runtime). Run the API in a Node-compatible in-browser runtime; PGlite itself needs only WebAssembly. Load-time cost: PGlite starts in about 1–2 s, once per instance.

  • Same SQL dialect as production, so migrations are plain Postgres .sql files in migrations/ (no translation layer, unlike the sqlite bond), and like / ilike behave exactly as on the postgresql bond. Each file runs ONCE (the _molecule_migrations table records it), as on the postgresql bond.