Versioning

Every PgFileSystem instance is bound to a version inside an active version root. The default version root is /, and any non-nested directory can become its own version root with mkdir(path, { versioned: true }). Versions are copy-on-write overlays, so the same path can hold different contents across versions without duplicating every row on fork.

Basic Usage

The version option selects which version an instance reads from and writes to in its active version root. If omitted, it defaults to "main".

// Scoped to (workspaceId, versionRoot: "/", version: "v2")
const v2 = new PgFileSystem({
  db: sql,
  workspaceId: "workspace-1",
  version: "v2",
})

await v2.writeFile("/config.json", '{"env":"staging"}')
await v2.readFile("/config.json") // '{"env":"staging"}'

// A different version sees nothing written in v2
const v3 = new PgFileSystem({
  db: sql,
  workspaceId: "workspace-1",
  version: "v3",
})
await v3.exists("/config.json") // false

Versioned Directories

A versioned directory is a normal directory with an independent version graph, similar to running git init inside it. Use versioned(path) to open a scoped facade; paths on that facade are relative to the versioned directory.

const fs = new PgFileSystem({ db: sql, workspaceId: "workspace-1" })
await fs.init()

await fs.mkdir("/database", { versioned: true })

const dbMain = await fs.versioned("/database")
await dbMain.writeFile("/schema.sql", "main")

const dbDraft = await dbMain.fork("draft")
await dbDraft.writeFile("/schema.sql", "draft")

await dbMain.readFile("/schema.sql")  // "main"
await dbDraft.readFile("/schema.sql") // "draft"
await dbMain.versionRoot               // "/database"

Version labels are scoped to the version root, so /database and /user can both have a draft version. Nested versioned directories are rejected.

Fork

fork(name) creates an O(1) child version and returns a PgFileSystem bound to it. No entry rows are copied. Reads fall through to the nearest ancestor row until the child writes or deletes that path.

const v1 = new PgFileSystem({
  db: sql,
  workspaceId: "workspace-1",
  version: "v1",
})

await v1.writeFile("/src/app.ts", "export default 1;")
await v1.writeFile("/readme.md", "# v1")

// Fork: link v1 -> v2, return an fs bound to v2
const v2 = await v1.fork("v2")

await v2.writeFile("/readme.md", "# v2 modified")

await v1.readFile("/readme.md") // "# v1"        (unchanged)
await v2.readFile("/readme.md") // "# v2 modified"

This is a live ancestor overlay, not a historical snapshot. If a parent changes after a child is forked, the child can still see that change for paths it has not shadowed. Call detach() when you need a standalone checkpoint.

List & Delete

// All versions present in the active version root
const versions = await v1.listVersions()
// ["v1", "v2", "v3"]

// Remove a version (and all its rows). Throws if it's the current one.
await v1.deleteVersion("v2")

Diffs

diff() compares the current visible tree to another version and returns changed paths with before/after entry metadata. Use diffStream() for keyset-paginated iteration over large trees.

const changes = await v2.diff("v1", { path: "/src" })
// [{ path: "/src/app.ts", change: "modified", before, after }]

for await (const change of v2.diffStream("v1", { batchSize: 500 })) {
  console.log(change.path, change.change)
}

Browsing History

listHistory() walks ancestors of the current version with keyset pagination, like git log. The includeChanges option controls how much per-entry detail comes back: false (default, metadata only), "paths" (cheap { path, change } summary), or true (full VersionDiffEntry with before/after shapes). All three modes share a single batched query, so paths-mode and full-changes mode are within ~5% of each other on large pages.

// 1. List page metadata + per-row "what changed" summary.
const page = await fs.listHistory({
  limit: 20,
  includeChanges: "paths",
})

for (const entry of page.entries) {
  // entry.versionId, entry.version, entry.parentVersion,
  // entry.createdAt, entry.deletedAt, entry.changes
  console.log(entry.version, entry.changes.length, "changes")
}

if (page.nextCursor) {
  const next = await fs.listHistory({
    limit: 20,
    cursor: page.nextCursor,
    includeChanges: "paths",
  })
}

For the click-to-detail flow, pass the numeric versionId from a history entry to versionDiff(). Unlike label-based diff(other), it works for the root entry (parent NULL → diff vs empty tree) and for deleted-but-retained entries.

// Click an entry → full diff against its parent.
const detail = await fs.versionDiff(page.entries[0]!.versionId)
// detail: VersionDiffEntry[] with full before/after shapes

// Or stream large diffs page-by-page.
for await (const change of fs.versionDiffStream(
  page.entries[0]!.versionId,
  { batchSize: 100 },
)) {
  console.log(change.path, change.change)
}

Retention & Sweep

By default, deleteVersion() physically removes a version's rows. Pass historyRetention: "retain" to keep deleted versions in history with deletedAt !== null so they still show up in listHistory() and can be diffed via versionDiff(). Run sweepHistory() to compact a retain-mode workspace back into self-contained snapshots and GC blobs no live entry references.

const fs = new PgFileSystem({
  db: sql,
  workspaceId: "workspace-1",
  version: "main",
  historyRetention: "retain",
})

// ... fork, edit, deleteVersion, promoteTo over time ...

const result = await fs.sweepHistory()
// {
//   keptVersions: 3,         // active labels materialised as snapshots
//   removedVersions: 12,     // deleted/orphaned versions physically removed
//   removedEntries: 487,
//   removedBlobs: 41,
// }

Merge, Cherry-Pick & Revert

merge() applies changes from another version into the current one using a three-way comparison against the lowest common ancestor. Conflicts fail by default, or you can resolve them with strategy.

const result = await draft.merge("feature", {
  strategy: "fail",      // "fail" | "ours" | "theirs"
  pathScope: "/src",
  dryRun: true,
})

if (result.conflicts.length === 0) {
  await draft.merge("feature", { pathScope: "/src" })
}

// Source-wins copy for selected paths, without LCA conflict checks.
await draft.cherryPick("feature", ["/src/router.ts", "/docs"])

// Restore selected paths to match another version.
await draft.revert("live", { paths: ["/config.json"] })

Detach & Rename

detach() materializes the current visible tree into the current version and severs ancestor dependencies. Use renameVersion() to move labels, or promoteTo() for the common detach-and-swap deploy flow.

// Freeze draft as an independent snapshot.
await draft.detach()

// Rename this version. With swap, an existing label is displaced.
const renamed = await draft.renameVersion("release-2026-04-28", {
  swap: true,
})

// Deploy helper: detach -> renameVersion(label, { swap: true })
const promoted = await draft.promoteTo("live", {
  dropPrevious: false,
})
// { label: "live", displacedLabel: "live-prev-..." }

Deploy Pattern

BashGres exposes versions as data. You can keep the live pointer in your own config, or reserve a label like live and use promoteTo() to atomically move that label to a detached draft. For a versioned directory, do this through its scoped facade.

// 1. Runtime reads use the live label inside /database
const runtime = await fs.versioned("/database", { version: "live" })
await runtime.readFile("/config.json")

// 2. Start editing in a fresh version forked from live
const draft = await runtime.fork("v3")
await draft.writeFile("/config.json", '{"env":"prod-v2"}')

// 3. Promote by detaching the draft and swapping the live label
await draft.promoteTo("live")

API Reference

MemberTypeDescription
versionreadonly stringThe version this instance is bound to
versionRootreadonly stringAbsolute workspace path that owns this instance's version graph.
versioned(path, opts?)Promise<PgFileSystem>Open a scoped facade for a versioned directory. Throws ENOTVERSIONED for normal directories.
fork(name)Promise<PgFileSystem>Create an O(1) child overlay and return an fs bound to it. Throws if name already exists or equals the current version.
diff(other, opts?)Promise<VersionDiffEntry[]>Compare this visible tree to another version, optionally scoped to a path.
diffStream(other, opts?)AsyncIterable<VersionDiffEntry>Stream the same diff with keyset pagination for large trees.
merge(source, opts?)Promise<MergeResult>Three-way merge from a source version into the current version.
cherryPick(source, paths)Promise<MergeResult>Source-wins copy of selected paths without LCA conflict checks.
revert(target, opts?)Promise<MergeResult>Restore selected paths in the current version to match a target version.
detach()Promise<void>Materialize visible entries and sever ancestor dependencies.
renameVersion(label, opts?)Promise<RenameVersionResult>Rename this version label, optionally displacing an existing label with swap.
promoteTo(label, opts?)Promise<PromoteResult>Detach, swap this version onto a label, and optionally drop the previous holder.
listVersions()Promise<string[]>Distinct versions present in the active version root
listHistory(opts?)Promise<VersionHistoryResult>Paginated walk of ancestor history with cursor-based pagination. includeChanges: false | "paths" | true.
versionDiff(versionId, opts?)Promise<VersionDiffEntry[]>Full diff for a single history entry by numeric versionId. Works for root and deleted-but-retained entries.
versionDiffStream(versionId, opts?)AsyncIterable<VersionDiffEntry>Streaming variant of versionDiff with keyset pagination by ltree path.
deleteVersion(name)Promise<void>Drop every row for name. Throws if name equals the current version.
sweepHistory()Promise<SweepHistoryResult>Compact a retain-mode workspace: materialise active labels into self-contained snapshots, drop inactive history, GC orphan blobs.