Storage
Store and Batch interface contract, path rules, error taxonomy, change-event semantics, GitStore signing.
This document describes the Store interface, the Batch contract, path validation rules, error taxonomy, and the ChangeEvent shape every Jeffs Brain SDK must implement. The TypeScript reference lives in packages/memory/src/store/.
Path validation
Paths are brand-typed (Path = string & { __brand: 'BrainPath' }) so that any function accepting a Path is guaranteed to have seen the validator. The validation rules, enforced by validatePath / toPath:
- Non-empty string.
- No NUL byte (
\0). - No backslash (
\). - No leading slash (
/foo/bar). - No trailing slash (
foo/bar/). - No
.or..path segments. - No empty path segments (i.e. no
//). - Must already be canonical:
cleanPosix(p) === pwherecleanPosixmirrors Go’spath.Clean.
Generated files are identified by a leading underscore on the base name (_index.md → generated). Generated files are hidden from listings by default; pass includeGenerated: true to surface them.
Shell-glob matching over base names follows the subset Go’s path.Match supports:
*matches any run of non-separator characters.?matches a single character.[...]matches one of the enclosed characters; leading!or^negates;a-zis a range.
Store interface
type Store = {
read(path: Path): Promise<Buffer>
write(path: Path, content: Buffer): Promise<void>
append(path: Path, content: Buffer): Promise<void>
delete(path: Path): Promise<void>
rename(src: Path, dst: Path): Promise<void>
exists(path: Path): Promise<boolean>
stat(path: Path): Promise<FileInfo>
list(dir: Path | '', opts?: ListOpts): Promise<FileInfo[]>
batch(opts: BatchOptions, fn: (batch: Batch) => Promise<void>): Promise<void>
subscribe(sink: EventSink): Unsubscribe
localPath(path: Path): string | undefined
close(): Promise<void>
}
Type shapes:
type FileInfo = { path: Path, size: number, modTime: Date, isDir: boolean }
type ListOpts = { recursive?: boolean, glob?: string, includeGenerated?: boolean }
type BatchOptions = { reason: string, message?: string, author?: string, email?: string }
type ChangeKind = 'created' | 'updated' | 'deleted' | 'renamed'
type ChangeEvent = {
kind: ChangeKind,
path: Path,
oldPath?: Path,
reason?: string,
when: Date,
}
type EventSink = (event: ChangeEvent) => void
type Unsubscribe = () => void
Semantics:
readreturns the full byte content or throwsErrNotFound.writereplaces content. Creates parent structure implicitly.appendcreates-then-extends; equivalent towrite(concat(prev, content))with no prior read if the file does not exist.deletethrowsErrNotFoundfor missing paths.renamemoves content atomically and overwrites the destination.ErrNotFoundwhen the source is missing.existsnever throws on “missing”; only on invalid paths or backend errors.statreturns{ path, size, modTime, isDir }; throwsErrNotFoundfor missing paths.listreturns entries sorted ascending by path. Non-recursive listings include one entry per immediate child (directory entries haveisDir: true). Recursive listings walk the subtree and omit directory entries.localPathreturns a filesystem path when the backend has one (FsStore, GitStore checkout) andundefinedotherwise (HttpStore, MemStore).closemakes the store unusable; further mutations throwErrReadOnly. Idempotent.
Batch semantics
batch(opts, fn) opens a write journal, runs fn(batch), and commits on successful return. The Batch handle offers the same read/write surface as Store minus batch, subscribe, localPath, and close.
Merging rules
The journal is replayed whenever a pending read happens and whenever the commit flushes to the backend. The reference replay(journal, path) resolves a single path into one of three states: present(content), deleted, or untouched.
- Write + Delete on the same path: the two cancel; the final state is
deletedif the file did not exist before the batch, ordeletedrelative to the batch’s committed state. - Write + Write on the same path: the later write wins.
- Append after Write: materialises as a single
writeof the concatenated content. - Rename: the
HttpBatchreference materialises rename aswrite(dst, content)followed bydelete(src)so the server sees a flat sequence. In-memory backends may implement rename natively, but the observable outcome is identical. - Read inside the batch returns the result of replaying the journal up to that point:
presentbuffers are served without a backend hit;deletedstates throwErrNotFound;untoucheddefers to the backend. - Exists and stat follow the same three-state replay before falling back to the backend.
- List fetches the backend listing, overlays pending creations, drops pending deletions, and re-applies the glob/generated/recursive filters over the merged result.
Atomicity guarantees per backend
- MemStore: in-process map. Atomic per JavaScript microtask.
- FsStore: POSIX filesystem backend. Atomic per
rename(2)swap for writes; batches buffer all mutations and apply them in sequence, rolling back on error. - GitStore: commit-per-batch. Produces one commit with the batch
reasonas the message subject. Atomic at the commit boundary. - HttpStore: journals locally, sends one
POST /documents/batch-opson commit. Atomic at the server’s transaction boundary. An error before the commit request means nothing reached the wire; a non-2xx response means the server rolled back.
All four backends share the invariant that a thrown error from fn leaves the brain untouched.
Error taxonomy
class StoreError extends Error # base class, name = 'StoreError'
class ErrNotFound extends StoreError # missing path
class ErrConflict extends StoreError # concurrent modification
class ErrReadOnly extends StoreError # store closed or backend is read-only
class ErrInvalidPath extends StoreError # path validation failed
class ErrSchemaVersion extends StoreError # on-disk schema version unsupported
Helpers: isNotFound, isInvalidPath, isReadOnly. Every SDK should expose equivalent sentinel types so cross-language tests can map them through the HTTP Problem+JSON error shape.
HTTP store error mapping:
- HTTP
404→ErrNotFound - HTTP
400→ErrInvalidPath - Anything else → generic
StoreErrorwrapping the parsed Problem+JSON payload
ChangeEvent shape and ordering
subscribe(sink) registers a sink that receives a ChangeEvent per committed mutation. Ordering guarantees:
- For single mutations, exactly one event fires after the backend commits.
- For batches, one event fires per materialised op, in the order
fnissued them. - Events carry the op reason when provided in
BatchOptions.reasonor directly on single-mutation calls that forward a reason (batch today). - HTTP stores deliver events asynchronously over SSE; subscribers may need to await one tick after a mutation before the sink fires.
subscribereturns anUnsubscribefunction. When the last subscriber unsubscribes on an HTTP store, the underlying SSE connection is torn down; re-subscribing opens a new connection.
oldPath is set on renamed events only. reason is optional on all events.
ErrConflict
ErrConflict signals that a mutation lost a race with a concurrent writer or with an upstream that has moved on. It is the SDK-level sentinel for optimistic concurrency failures. SDKs in other languages MUST expose an equivalent sentinel so cross-language tests can map through the HTTP Problem+JSON code.
Throw sites in the TypeScript reference (gitstore.ts):
GitStorepush rejection: whengit pushfails as a non-fast-forward, the store raisesErrConflictwith detailgit push rejected by <remote>/<branch>. Triggered by a remote that advanced since the last pull.GitStorerebase conflict: when the auto-rebase step during pull hits a merge conflict the store aborts the rebase and raisesErrConflictwith detailgit pull conflicted while rebasing on <remote>/<branch>.GitStorestash-pop conflict: when a pull succeeded but re-applying the auto-stash conflicts the store raisesErrConflictwith detailgit pull restored remote changes but local stash pop conflicted.
MemStore and FsStore do not raise ErrConflict; they serialise mutations in-process and never observe contention. Adapters that bolt on optimistic concurrency (the Postgres adapter at sdks/ts/memory-postgres/src/store.ts, memory-openfga wrappers that enforce row-version checks) raise ErrConflict from write, append, delete, rename, and batch when the on-disk version does not match the expected version.
Wire representation over HTTP (see spec/PROTOCOL.md error codes table):
- HTTP status
409 Conflict. - Problem+JSON
code:"conflict". detailSHOULD include the contending resource (path, branch, or batch description) so clients can surface it to humans.
{
"status": 409,
"title": "Conflict",
"detail": "brain: concurrent modification: git push rejected by origin/main",
"code": "conflict"
}
Clients SHOULD NOT retry ErrConflict automatically. Callers that want last-writer-wins semantics MUST refetch current state, rebase their intended mutation on top, and retry explicitly.
Event ordering
subscribe(sink) returns an Unsubscribe handle. The backend publishes to every live sink; the rules that follow apply identically across MemStore, FsStore, GitStore, and HttpStore.
Per-store total order. A single store instance publishes events in commit order. For a batch of N operations the store emits N events, one per materialised op, in the order they were journalled.
Per-sink delivery. Every registered sink receives the complete stream. Fan-out is synchronous inside the emitter: the store iterates its sink set and invokes each sink before returning from the commit path. Sinks observe events in the same order as the store’s emit order; there is no per-sink reordering.
Concurrency and reentrancy. Sinks are invoked serially. A sink that throws does not short-circuit the fan-out; emitters MUST catch and log, never propagate. A sink that itself mutates the store observes its own events; callers responsible for avoiding infinite loops.
Delivery guarantees:
MemStore,FsStore,GitStore: at-most-once. Events fire exactly once per committed mutation to every sink registered at the momentemitruns. Sinks subscribed after the commit completes MISS the event; there is no replay buffer.HttpStore: at-most-once over SSE. The server emits onechangeframe per committed mutation. A client that disconnects MISSES every frame produced while it was absent. Reconnecting opens a fresh stream with no replay.Last-Event-IDis NOT honoured in v1.0 (reserved).
Client cursor semantics:
- v1.0 does not expose a replay cursor. Clients that need durable change tracking MUST layer their own journal over the store (for example, by writing an append-only log on every mutation).
- The
id:field on SSE frames is monotonically increasing per stream attach (seespec/PROTOCOL.md) but MUST NOT be interpreted as a cross-attach cursor. Reserved for v1.1.
Ordering across subscribers to the same store is guaranteed: if sink A observes event E1 before E2, sink B observes the same ordering. There is no causal-only mode; per-store total order is the contract.
GitStore signing
GitStore supports a pluggable commit signing callback so deployments can integrate with SSH agents, KMS, GPG, or any other key custodian without baking cryptography into the SDK. The helpers live in sdks/ts/memory/src/store/gitstore.ts.
Types:
type GitSignPayload = { payload: string }
type GitSignFn = (payload: GitSignPayload) => Promise<string>
GitSignPayload.payload is the UTF-8 serialised commit object that would be hashed to produce the commit SHA. GitSignFn returns the detached signature as an ASCII-armored string (for example, a PGP -----BEGIN PGP SIGNATURE----- block or an SSH -----BEGIN SSH SIGNATURE----- block). The signature is embedded verbatim in the commit header; callers own the key material.
Invocation points. The callback is invoked by isomorphic-git whenever a commit object is about to be written:
- Every batch commit in
commitTouched(the single commit produced by each call toStore.batch). - The
[init]bootstrap commit produced byinitialiseRepositorywhen the store creates a brand-new repository.
The callback is NOT invoked on:
- Tag creation (v1.0 does not tag).
git push(transport authentication is handled by the OS git binary and is out of scope for the SDK).FsStoreorMemStoremutations.
Wiring. GitStoreOptions.sign is optional. When present, GitStore passes an internal signingKey placeholder to isomorphic-git purely to arm the signing path; the placeholder is never exposed to the callback. When absent, commits are unsigned and the placeholder is never set.
const store = await createGitStore({
dir: '/var/brain/main',
init: true,
sign: async ({ payload }) => signWithSshAgent(payload),
})
Language SDKs. Go and Python SDKs MUST expose the same two-type shape (GitSignPayload with a single payload: string field; GitSignFn taking the payload and returning a future-of-string). The callback MUST receive the pre-hashed commit object verbatim and MUST return the detached armored signature. Implementations MUST invoke the callback at every batch commit and the init commit, and MUST NOT invoke it for tags or pushes in v1.0.