A scope bundling one or more sheet mutations into a single commit.
A Transaction is the unit of atomicity. Inside a transaction, mutations stage to a private tree built from the parent ref; on success the tree commits and the configured branch advances. On throw, the tree is discarded — no commit, no ref movement.
Transactions are created by Repository.transact(opts, handler) and (in typed Store usage) by Store.transact(opts, handler). They are not directly constructible by consumers.
1. parent ref resolves → parentCommitHash
2. private tree built → copy of parent's tree
3. handler runs → mutations stage to the private tree
4. handler resolves → finalize: validate, write tree, commit, update ref
handler throws → discard tree, no commit, no ref movement
5. transaction closed → mutex released
The handler receives a tx object. The Sheet handles obtained from tx.sheet(name) are scoped to the transaction's tree — their writes do not become visible to other readers until the transaction commits.
await repo.transact({ message: '...' }, async (tx) => {
const users = tx.sheet('users');
await users.upsert({ slug: 'jane', email: 'jane@x.org' });
const audit = tx.sheet('audits');
await audit.upsert({ action: 'user.create', subject: 'jane' });
return { created: 'jane' };
});tx.sheet(name, opts?) returns a Sheet with the same API as the outer Repository.openSheet(name) — except all writes route through the transaction's private tree. opts accepts validator?: StandardSchema (used by Store.transact to thread per-sheet validators through to tx scope) and prefix?: string (sub-prefix under the sheet's configured root — same shape as Repository.openSheet({ prefix }); useful when a multi-tenant request handler opens a transaction and needs every sheet within it scoped to one tenant).
Reads through tx.sheet(name) see the transaction's in-flight mutations. Reads outside the transaction (e.g., a concurrent repo.openSheet(name).queryFirst(...)) see the committed state — the pre-transaction tree — until the transaction commits.
The commit's message is structured:
<subject>
<optional body>
Trailer-Key: trailer-value
Another-Trailer: another-value
- Subject = the
messageoption (first line). - Body = anything after the first line of
message. - Trailers = appended per
git interpret-trailersrules. See behaviors/transactions.md for the format.
Trailer keys use HTTP-header style: first letter capitalized, multi-word hyphenated, rest lowercase (Subject-Id, User-Agent, Action).
- Author =
opts.authoror git configuser.name/user.email. - Committer =
opts.committeroropts.author. - Both can be omitted entirely if git config is set.
One open transaction per Repository at a time, enforced two ways:
- Nested
repo.transact(...)— callingrepo.transactfrom within an open transaction's handler (same async context, detected viaAsyncLocalStorage) throwsTransactionError(transaction_in_progress) immediately. Usetx.sheet(name)inside the handler instead of re-opening a transaction. - Concurrent-but-independent
repo.transact(...)— a call from a separate async context while another transaction is open does not throw; it queues on an in-process mutex and runs after the open one commits or releases. (The Rust core exposes a throwing per-repo single-writer slot; the host binding wraps it with this fair queue, so well-behaved concurrent callers serialize rather than fail.) See behaviors/transactions.md.
Mutations made outside any open transaction (permissive mode) implicitly open and commit a single-mutation transaction; they too contend for the mutex.
In strict mode (repo.requireExplicitTransactions()), mutations outside repo.transact(...) throw TransactionError (transaction_required).
- If
opts.parentis a branch name (e.g.,main), the transaction's parent commit ismain's tip at transaction open; on commit,mainadvances to the new commit. - If
opts.parentis a commit hash, the transaction commits onto that hash; ifopts.branchis also set, that branch is updated. - If neither is set, defaults to
HEAD's ref.
If the parent ref moves between transaction open and commit (a separate process committed), the transaction throws TransactionError (parent_moved). The handler's tree changes are discarded.
finalize skips the commit entirely when the resulting tree-hash matches the parent commit's tree-hash:
new_tree_hash := await workspace.root.write()
if parentCommitHash !== null and (parentCommitHash^{tree}) === new_tree_hash:
return { value, commitHash: null, treeHash: null, ref: null, parentCommitHash }
Two conditions must both hold to detect the no-op:
- A parent commit exists. On a fresh repo (
parentCommitHash === null), an initial commit IS produced even if the tree is empty — there's no parent tree to compare against. This is intentional: an initial commit is a meaningful event the consumer asked for. - The resulting tree hash equals the parent's tree hash. This is the canonical git-native "did anything actually change?" check, computed in O(tree-depth) by
git rev-parse <commit>^{tree}.
When both conditions hold, the return shape matches the existing !anyMutation short-circuit (commitHash: null, treeHash: null, ref: null). Consumers detect a no-op via result.commitHash === null.
This means the following all produce no commit:
upsert(record)where the record's canonical bytes match the existing blobclear()+ re-upsertfrom a snapshot whose data is unchanged from the parent'sdelete(path)+upsertof an identical record at the same path- An explicit
tx.markMutated()call that isn't accompanied by an actual tree mutation
The last bullet is a behavior change from v1.3.0, where markMutated() alone forced a commit. Real consumers of markMutated (internal callers like upsert/delete/setAttachments) always pair it with an actual mutation, so this isn't expected to break anyone. Consumers who genuinely want empty commits should use git commit --allow-empty outside the library.
Standalone mutations (mutations called without an enclosing repo.transact) auto-open a transaction with a generated message:
<sheet> upsert <path>
or <sheet> delete <path>, etc. The author defaults to git config. The branch defaults to HEAD's ref.
This makes simple scripts ergonomic. For request-bound workflows, consumers should call repo.transact explicitly to control the message/author/trailers.
None in v1.0. Pre-commit and post-commit hook points are deliberately not exposed — the API can grow them later without breaking existing callers.
const { value, commitHash } = await repo.transact(
{
parent: 'main',
author: { name: 'Jane Doe', email: 'jane@x.org' },
message: 'janedoe: POST /api/users\n\nCreated by sign-up flow',
trailers: { Action: 'user.create', 'Subject-Slug': 'janedoe' },
},
async (tx) => {
return tx.sheet('users').upsert({ slug: 'janedoe', email: 'jane@x.org' });
}
);await repo.transact(
{ message: 'admin: project.delete squadquest' },
async (tx) => {
await tx.sheet('projects').delete({ slug: 'squadquest' });
for await (const m of tx.sheet('memberships').query({ projectSlug: 'squadquest' })) {
await tx.sheet('memberships').delete(m);
}
}
);repo.requireExplicitTransactions();
await sheet.upsert({ ... }); // throws TransactionError: transaction_required
await repo.transact({ message: '...' }, async (tx) => {
await tx.sheet('users').upsert({ ... }); // ok
});| Class | Code | When |
|---|---|---|
TransactionError |
transaction_in_progress |
Nested repo.transact inside an open transaction's handler (independent-context calls queue instead — see Concurrency) |
TransactionError |
transaction_required |
Mutation outside a transaction in strict mode |
TransactionError |
parent_moved |
Optimistic-concurrency conflict at commit |
TransactionError |
commit_failed |
git commit-tree / update-ref returned non-zero, or a malformed trailer key/value was rejected at option-normalization time (HTTP-header-style key, string value with no newlines) |
RefError |
ref_not_found |
opts.parent is a branch name that doesn't exist |
| (any) | (any) | Errors thrown by the handler propagate out after tree discard |