Migrating from pre-v1.0 ¶
If you’ve been running a pre-v1.0 internal install, here’s what changes and how to update.
What’s removed entirely ¶
- The
GitSheetsclass (backend/lib/GitSheets.js) — early-prototype API. Replaced byRepository+Sheet+Transaction. - The HTTP server (
backend/server.js) — Koa-based REST surface. The library no longer ships an HTTP layer; build your own with Fastify / Koa / Express / Hono and call the JS API. - The Vue frontend (
src/*Vue,tests/e2eCypress) — demo UI. If you need a UI, build it as a separate consumer. - CSV-ingest commands (
backend/commands/singer-target.js) — Singer target. Not in the v1.0 surface. deepmergefor--patch-existing— replaced by RFC 7396 JSON Merge Patch (Sheet.patch(query, partial)).
What’s reshaped ¶
gitsheetsis now a single npm package at the repo root (wasbackend/).- TypeScript-first, ESM-only.
"type": "module". Node ≥ 20 or Bun ≥ 1. [gitsheet.fields]config moves to[gitsheet.schema](JSON Schema). Thesortrules stay where they are. See the migrating-config recipe.- Errors are typed classes. No more string-matching on
err.message. Seeapi/errors.mdfor the hierarchy + stable codes.
API translation table ¶
| Pre-v1.0 | v1.0 |
|---|---|
new GitSheets({ gitDir }) |
await openRepo({ gitDir }) |
gitsheets.getSheet(name) |
await repo.openSheet(name) |
sheet.upsert(record) |
sheet.upsert(record) (same name, now goes through a transaction) |
sheet.delete(query) |
sheet.delete(query) (same name) |
sheet.query(filter) |
sheet.query(filter) async iterator (same name) |
sheet.commit(message) |
repo.transact({ message }, async tx => { await tx.sheet(...).upsert(...) }) |
--patch-existing (CLI, deepmerge) |
--patch (CLI, RFC 7396 — semantic change: null deletes, arrays replace) |
HTTP POST /sheets/:name |
Your own HTTP layer + sheet.upsert(...) |
Permissive vs strict mode ¶
Pre-v1.0, every upsert mutated the working tree and required an explicit commit() to make changes durable. v1.0 inverts this:
- Permissive default — standalone
sheet.upsert(record)auto-opens a transaction with an auto-generated commit message. Simple scripts just work. - Strict mode — call
repo.requireExplicitTransactions()to require every write to go throughrepo.transact. Useful when you want every commit to carry intentional metadata (author, message, trailers).
// Permissive (default)
await sheet.upsert({ slug: 'jane', email: 'jane@x.org' });
// → commits with message "users upsert"
// Strict
repo.requireExplicitTransactions();
await sheet.upsert({ slug: 'jane', email: 'jane@x.org' });
// ✗ throws TransactionError('transaction_required')
await repo.transact({ message: 'add jane' }, async (tx) => {
await tx.sheet('users').upsert({ slug: 'jane', email: 'jane@x.org' });
});
// ✓
For production / request-bound flows, prefer strict mode + explicit repo.transact — see the request-bound transactions recipe.
A worked translation ¶
Pre-v1.0 ¶
const { GitSheets } = require('gitsheets');
const gs = new GitSheets({ gitDir: '/data/.git' });
await gs.init();
const users = await gs.getSheet('users');
await users.upsert({ slug: 'jane', email: 'jane@x.org' });
await users.upsert({ slug: 'bob', email: 'bob@x.org' });
await users.commit('add users');
const found = await users.queryFirst({ slug: 'jane' });
v1.0 ¶
import { openRepo } from 'gitsheets';
const repo = await openRepo({ gitDir: '/data/.git' });
const users = await repo.openSheet('users');
await repo.transact(
{ message: 'add users' },
async (tx) => {
const s = tx.sheet('users');
await s.upsert({ slug: 'jane', email: 'jane@x.org' });
await s.upsert({ slug: 'bob', email: 'bob@x.org' });
},
);
const found = await users.queryFirst({ slug: 'jane' });
Differences:
- ESM
importinstead of CommonJSrequire openRepo(factory) instead ofnew GitSheets()+await gs.init()openSheetinstead ofgetSheet(factory consistency)- Bundle multiple upserts into one transaction with
repo.transact— same shape, atomic, one commit queryFirstis unchanged
Validation reshape ¶
If you had [gitsheet.fields] configs, see the migrating-config recipe for the field-by-field migration. The short story:
type/enum/defaultmove to[gitsheet.schema.properties.<name>]sortstays at[gitsheet.fields.<name>.sort](different concept — canonical normalization)trueValues/falseValuesmove to your CSV ingest code (no longer a sheet-config concern)
Error handling ¶
Pre-v1.0 you might have done:
try {
await users.upsert(record);
} catch (err) {
if (err.message.startsWith('invalid tree ref')) { /* ... */ }
}
v1.0:
import { GitsheetsError, ValidationError, RefError } from 'gitsheets';
try {
await users.upsert(record);
} catch (err) {
if (err instanceof ValidationError) {
// err.issues — structured ValidationIssue[]
} else if (err instanceof RefError) {
// err.code === 'ref_not_found' | 'not_an_ancestor'
} else if (err instanceof GitsheetsError) {
// err.code — stable string
// err.status — HTTP status hint
} else {
throw err;
}
}
Every gitsheets error extends GitsheetsError and carries a stable code. See specs/api/errors.md for the full hierarchy + code table.
CLI changes ¶
git sheet upsert/query/read/normalize— same command shape, rebuilt against the new coregit sheet edit <sheet> <path>— new in v1.1:$EDITOR-based round-trip on a recordgit sheet init <sheet>— new in v1.1: scaffold a starter.gitsheets/<sheet>.tomlgit sheet infer <sheet>/migrate-config <sheet>— new in v1.1: schema inference and pre-v1.0 fields-config migration--patch-existing→--patch(RFC 7396 semantics:nulldeletes, arrays replace)--format(json|toml|csvupsert;json|csv|tsv|tomlquery/read),--encoding,--delete-missing,--attachment <name>=<source>— all shipped in v1.1--prefix(GITSHEETS_PREFIX) for multi-tenant sub-tree scoping- New global flags:
--message,--author-name,--author-email,--trailer Key=Value,--ref,--commit-to - Exit codes are stable from v1.0 onward — see CLI reference
--working(read/write the working tree state) remains deferred — tracked at #165
v1.1 → v1.2 ¶
Fully additive minor release. Existing v1.1 code keeps working.
Library ¶
- Content-typed records. A sheet can opt into
[gitsheet.format] type = 'markdown'(or'mdx') to store records as.mdfiles with TOML frontmatter and a designated body field. Bodies are normalized throughmarkdownlint --fixon write; the frontmatter stays canonical TOML. Default remains TOML — existing sheets are unchanged. Seebehaviors/content-types.mdand the Markdown CMS recipe. - Lazy body loading.
Sheet.query/queryFirst/queryAllacceptopts.withBody(defaulttrue). SettingwithBody: falseon a markdown sheet skips body bytes entirely — useful for listing pages and bulk metadata reads. Hydrate on demand withawait sheet.loadBody(record). Index builds always use body-less reads (don’t index on body content). No effect on TOML sheets. Sheet.upsert(record, opts?). Addsopts.allowMissingBodyfor content-typed sheets — explicit opt-in to upsert a record without a body field. Default behavior throws if the body field is missing, so a body-less upsert can’t silently erase on-disk content.Sheet.patchhandles{ body: null }deletions transparently (it passesallowMissingBody: trueto the internal upsert).- New public types:
Format,FormatConfig,UpsertOptions.
CLI ¶
gitsheets check <sheet> <file> [--fix]— new. Verify a record file in the working tree is parseable, schema-valid, and canonical. With--fix, rewrite to canonical. Never commits. Designed for post-edit hooks (--fix) and CI pre-commit verification (no--fix). Exit codes: 0 ok, 1 not-canonical (no--fix), 22 ValidationError, 64 ConfigError.gitsheets query --no-body— new flag. For content-typed sheets, suppresses the body field in output. No effect on TOML sheets.
Tooling ¶
skills/gitsheets/— bundled Claude Code skill for developers consuming gitsheets. Reference material covering the CLI, the TypeScript API, and the sheet config syntax. Install it in your.claude/skills/(or via plugin) to give Claude focused gitsheets context.
v1.2 → v1.3 ¶
Fully additive minor release. Existing v1.2 code keeps working.
Library ¶
Sheet.willChange(record, opts?)— new in v1.3.0. Pre-flight idempotency check that runs upsert’s full validation + normalization + serialization pipeline and compares the resulting bytes to the existing blob at the rendered path — without mutating the tree. Returns{ changed, path, currentBlobHash?, nextText }. Consumers that want commit-skipping semantics (“only commit if something actually changed”) can pre-flight + skip whenchanged: false. Throws the same errorsupsertwould.- Title from body’s H1. Content-typed sheets can opt into
[gitsheet.format].title = '<field>'to denormalize the body’s first H1 into a frontmatter field. The library enforcesrecord.title === <body's first H1, or undefined>on every write —upsertwith disagreeing values throwsValidationError;Sheet.patch({title: 'X'})rewrites the body’s H1 for you,Sheet.patch({body: '# Y\n…'})re-derives the title. Markdownlint’sMD041auto-enables to fail loud on bodies that start with prose. Fully backward-compatible — sheets without[gitsheet.format].titlebehave exactly as v1.2. Seebehaviors/content-types.mdand the Markdown CMS recipe. - New utility exports:
parseToml,parseConfigToml,stringifyRecord(TOML round-tripping),getFormat,hasFormat,registerFormat,resolveFormatConfig(format dispatch). All were already in the package’s internal modules; v1.3 surfaces them at the package root for consumers who need raw-bytes round-tripping or custom format registration. - New public type:
WillChangeResult.
Tooling ¶
gitsheets-axi— new sibling npm package. Agent-facing CLI with TOON output, idempotent mutations, self-installing session hooks (Claude Code + Codex), format-aware default schemas. Lockstep-versioned with the library on minor. Seedocs/axi.md. Install withnpm install -g gitsheets-axi.
Repository layout (no consumer impact) ¶
- The repository was promoted to an npm workspaces monorepo. The published
gitsheetspackage now lives atpackages/gitsheets/; the agent-facing companion atpackages/gitsheets-axi/. Consumer code is unaffected —import { ... } from 'gitsheets'resolves the same surface as before, and the published tarball has the same shape.
CLI ¶
No new commands on the human gitsheets CLI in v1.3. The agent-facing operations (TOON output, idempotent mutations) live in gitsheets-axi rather than as flags on the human CLI — the two contracts disagree on too many defaults to coexist in one binary.
v1.3.0 → v1.3.1 ¶
Patch release. Two fixes in service of snapshot-importer-style workflows.
Library ¶
Sheet.clear()is now O(1). Previously walked the sheet’s subtree and calleddeleteChildfor every entry — fine at sheet sizes of a few hundred records, problematic at tens of thousands. The new implementation uses hologit’sTreeObject.clearChildren()(added in hologit 0.50.2) to point the subtree at git’s empty-tree hash in constant time. Same observable behavior; existing code unchanged.-
No-op transactions no longer produce empty commits.
Transaction#finalizenow compares the resulting tree-hash to the parent commit’s tree-hash; equality means the staged state matches the parent and no commit is created. Concretely, every “clear + re-upsert from snapshot” pattern against unchanged upstream data is now a clean no-op (commitHash === null). Same goes forupsert(record)where the record’s canonical bytes match what’s on disk, anddelete+ re-upsertof an identical record.Subtle behavior change: an explicit
tx.markMutated()call that isn’t paired with a real tree mutation no longer produces a commit. Internal callers (upsert/delete/setAttachments/etc.) always pairmarkMutatedwith an actual mutation, so this shouldn’t affect any real consumer. Consumers who genuinely want empty commits should usegit commit --allow-emptyoutside the library.
Dependency ¶
hologitbumped from^0.49.1to^0.50.2to pick upTreeObject.clearChildren()(the O(1) primitive backing the newSheet#clear()).
Going forward ¶
Once migrated, the recipes are the fastest path to common patterns:
If you hit something the migration guide doesn’t cover, open an issue — likely a missing migration note, possibly an actual gap.