Stash

The policy layer of the store. Stash owns identity (refs), lifecycle, and integrity; the backend it wraps owns bytes. You put bytes in with push and get a ref back -- a 256-bit random capability, not a content address. You read them back with apply, inspect with show / list, and destroy with drop / clear.

The verb set is git stash's, and the correspondence is the contract: lifecycle verbs the git command doesn't have don't get added, and the ones it has don't get renamed. Entries are write-once and their lifecycle is monotone -- every state change moves an entry closer to destruction, never further.

The store is crypto-agnostic by architecture: there is no key parameter on any method and no cipher import anywhere in the tree. Encryption belongs to the consumer; StashJS is a shelf.

A Stash is an EventEmitter. It emits 'pushed' (Entry) after a push commits, 'popped' (Entry) after a pop's delete lands, 'dropped' (Entry) on drop / clear / a budget-exhausting read, 'expired' (Entry) exactly once per reaped entry, and 'sweepError' (Error) -- never 'error', which would crash the process -- when a background sweep throws. Payloads are full, defensive-copy Entry objects emitted after the change commits, so a throwing listener cannot unwind the committed operation.

stash.Stash

since 0.1.0 stable
new Stash(opts) -> Stash

Construct a store over a backend. opts.backend is required and must implement the backend contract (SPEC.md 9). opts.ttl ('30m', '24h', '7d', a number of ms, or null for no expiry) is the construct-time default expiry for every push, overridable per call. opts.sweepInterval (same duration forms) arms a background prune() timer; the timer is .unref()'d, so an open Stash never holds the process open on exit -- still, call close() (or await using) on shutdown, since an unref'd timer keeps the Stash reachable. A sweepInterval above Node's ~24.8-day timer ceiling, or of zero, is a config-time TypeError, not a silent busy loop.

opts.maxSize bounds each entry (a size string like '100mb' or a byte count); a push that exceeds it aborts mid-stream with SizeExceeded, leaving no partial behind. opts.maxEntries and opts.maxTotal bound the whole store; a push that would exceed either is refused with StashFull, and nothing existing is evicted to make room. Expired-but-unswept entries are pruned before the store is judged full, so they never block a live push. A stats read and the write are not atomic, so concurrent pushes can overshoot a stash-wide bound by the number in flight -- the bound stops unbounded growth, not that exact byte. maxTotal counts the stored footprint -- each blob plus its metadata, not the blob alone -- so many tiny blobs carrying large meta cannot slip past it. It is a ceiling you set, not one the disk enforces: size it below the backing partition's free space (and keep maxSize at or below it), or the filesystem fills before the limit fires.

opts.onPopFailure decides what happens to an entry whose pop (or budgeted apply) fails to fully drain -- a stream destroyed early, a source error, a digest mismatch: 'restore' (the default) returns the entry so the read can be retried, 'burn' destroys it anyway. opts.claimTimeout (a duration string or a number of ms, default '10m', and strictly POSITIVE) is how long a claim left by a crashed process is treated as still live before recovery reclaims it on the next construction; a claim younger than this is left untouched. Recovery NEVER reclaims a claim a live reader in THIS process is still draining -- single-writer-per-root means the process tracks its own live claims, so the age test (and the wall clock behind it, which a forward step can jump) is consulted only for an ORPHAN with no live holder, i.e. a crashed prior run's. claimTimeout therefore bounds how long an orphan sits before recovery resolves it: set it to comfortably exceed the longest pop/budgeted read, and keep a disk root to a single writing process. A non-positive claimTimeout is refused at construction. opts.tombstoneTtl (a duration string, a number of ms, or null to never prune; default '30d') is how long a destruction's grave is kept before pruning -- size it above the longest gap between replica reconciliations or a forgotten grave lets an id come back. opts.digest picks the integrity hash for new pushes -- 'sha256' (the default), 'sha512', 'sha3-256', 'sha3-512', or 'shake256'; the stored digest is self-describing, so a read verifies with the entry's OWN algorithm and one store may mix them. Every option this constructor accepts is enforced; an unknown one is a config-time TypeError.

Example

import { Stash } from "@blamejs/stash";
import { MemoryBackend } from "@blamejs/stash/backends/memory";

const stash = new Stash({ backend: new MemoryBackend(), ttl: "24h" });

References

stash.push

since 0.1.0 stable
stash.push(source, opts) -> Promise<string>

Store bytes; resolve to the entry's ref. The source may be a Buffer, a Uint8Array, a UTF-8 string, a Readable, or any AsyncIterable of chunks; it streams through to the backend, which computes size and the digest as the bytes pass -- with the algorithm chosen at construction (sha256 by default). opts.meta is a caller-owned plain object, round-tripped verbatim as JSON and never interpreted. opts.ttl overrides the constructor default for this entry (null overrides a default back to no expiry); an absent ttl inherits the default. opts.reads is a read budget: a positive integer count of successful apply drains after which the entry self-destructs (null, the default, is unlimited). A budgeted apply spends one credit only on a full, digest-verified drain -- an abandoned or corrupted read costs nothing -- and the read that takes the budget to zero destroys the entry. Terms are fixed at push and only move the entry toward destruction -- there is no touch or extend. The ref is random -- a capability, not a content address. Construct-time maxSize bounds this entry (SizeExceeded, thrown mid-stream), and maxEntries / maxTotal bound the whole store (StashFull); a rejected push leaves nothing behind.

Example

const ref = await stash.push(ciphertext, { meta: { kind: "drop" }, ttl: "1h" });

References

stash.apply

since 0.1.0 stable
stash.apply(ref) -> Promise<Readable>

Stream an entry's bytes without destroying it. The stream is digest-verified as it drains: a corrupted blob errors the stream with IntegrityError rather than delivering silently bad bytes. An unknown OR expired ref rejects with RefNotFound -- an expired entry is dropped in passing and never streamed, even if the sweeper has not run; a malformed ref dies at the whitelist with InvalidRef before any storage access.

An entry pushed with a read budget (reads) is instead claimed for the read: concurrent readers serialize through the claim (the loser rejects RefClaimed), one credit is spent only on a full, digest-verified drain, and the read that exhausts the budget destroys the entry through the same path as pop. An unbudgeted entry stays lock-free and pays nothing for the feature.

Example

const readable = await stash.apply(ref);
for await (const chunk of readable) sink.write(chunk);

References

stash.pop

since 0.1.7 stable
stash.pop(ref) -> Promise<Readable>

Read an entry's bytes and destroy it: the stream is digest-verified as it drains, and the entry is deleted the instant it drains cleanly -- bytes out once, then gone. Pop ignores any read budget; it is terminal by definition.

The claim is atomic at the filesystem: two concurrent pop(ref) race on the claim, exactly one wins and drains, the other rejects RefClaimed (ECLAIMED). A stream that errors, is destroyed early, or fails its digest is resolved by onPopFailure -- 'restore' (default) returns the entry so the read can be retried, 'burn' destroys it anyway. An unknown, expired, or already-claimed ref rejects (RefNotFound / RefClaimed); a malformed ref dies at the whitelist with InvalidRef before any storage access.

Example

const readable = await stash.pop(ref); // drains, then the entry is gone
for await (const chunk of readable) sink.write(chunk);

References

stash.store

since 0.1.9 stable
stash.store(entry, source) -> Promise<boolean>

The replication-grade insert (SPEC.md 4.4): file an already-created entry, preserving its identity where push mints a new one. The caller supplies the COMPLETE Entry -- id, createdAt, expiresAt, reads, readsLeft, digest, meta -- and it lands verbatim; the bytes are verified against the supplied digest and size as they stream, so transfer corruption is caught on the way in and nothing lands. It proceeds in a normative order:

1. a malformed id is InvalidRef, before any storage access; 2. a tombstoned id returns false, writing nothing -- a destroyed id never comes back; 3. an entry already past its expiresAt is a no-op false (the dead travel as dead, and get no grave); 4. an identical live entry (same id, same digest) is an idempotent no-op false, so a retry-based sync is free; 5. same id, different digest is an IntegrityError -- corruption, not a merge; the existing entry is untouched; 6. otherwise it writes exactly like push, every field the caller's, and returns true.

A genuinely new entry (past step 5) is charged against the stash bounds exactly as a push is: a replica larger than maxSize aborts SizeExceeded, and one past maxEntries / maxTotal is refused StashFull -- replication input does not get to slip the configured capacity. Concurrent stores of the SAME id are serialized so two conflicting replicas cannot both land (the loser sees the winner and reconciles: idempotent-false or an IntegrityError).

store emits NO event: a sync daemon that heard its own writes would echo them back forever, so the silence removes that bug class here rather than in every caller. The replicated entry is untrusted input -- a shape violation, a digest that is not a well-formed <algo>:<hex> for a registry algorithm, an incoherent read budget, or a non-plain meta is an IntegrityError, never a partial write.

A read budget is enforced per store, so two replicas of a reads: 1 entry can each serve one full read before their tombstones converge -- exactly-once becomes eventually-once. Serve reads from a single node (cold standby) unless that weaker guarantee is a deliberate choice.

Example

for (const e of await primary.list()) await replica.store(e, bytesFor(e.id));

References

stash.show

since 0.1.0 stable
stash.show(ref) -> Promise<Entry>

Resolve a ref to its Entry -- metadata only, never contents. The Entry is a defensive copy; entries are write-once and nothing a caller does to the returned object changes the store. An expired ref is dropped in passing and rejects with RefNotFound, the same as an unknown one.

Example

const entry = await stash.show(ref);
entry.size; // bytes

References

stash.has

since 0.1.8 stable
stash.has(ref) -> Promise<boolean>

Existence check without the try/catch show needs. true for a live entry, false for an unknown OR expired ref -- an expired entry is reaped in passing, exactly as show/apply treat it. A malformed ref still dies at the whitelist with InvalidRef BEFORE any backend access -- a boolean query is not a licence to fail open on hostile input -- and a corrupt entry throws IntegrityError rather than answering false: a clean boolean must never hide corruption.

Example

if (await stash.has(ref)) console.log("still present");

References

stash.stats

since 0.1.8 stable
stash.stats() -> Promise<{ entries, bytes, claimed }>

Aggregate counts, never refs: entries (live plus expired-but-unswept), bytes (the stored footprint -- each blob plus its metadata), and claimed (in-flight pop / budgeted-read claims). The object carries exactly those three keys. Aggregates are the physical truth of the shelf: an expired entry still counts until a read or prune() reaps it, so it is a fast lstat walk. A foreign file in the layout fails the aggregate loudly with IntegrityError, never a silently smaller number; content integrity (a corrupt sidecar, a bit-flipped blob) is verify()'s job, not this count's.

Example

const { entries, bytes, claimed } = await stash.stats();

References

stash.tombstones

since 0.1.9 stable
stash.tombstones() -> Promise<Tombstone[]>

The graves, for reconciliation: { id, destroyedAt, cause }[] -- an id that was destroyed, when (ms since the epoch), and how ('pop' / 'drop' / 'clear' / 'spent'), and NOTHING that describes the body (no digest, size, or meta -- recording those would leak the content the destruction removed). Expiry leaves no grave (terms travel with the entry). A query: it inspects the shelf, never moves bytes, and is loud over a corrupt grave, the same as list(). Feed each id to a replica's drop() to converge the two stores.

Example

for (const grave of await primary.tombstones()) await replica.drop(grave.id);

References

stash.verify

since 0.1.8 stable
stash.verify(opts?) -> Promise<Report>

Audit the store's physical integrity. Dry-run by default: it digest-checks every blob (streamed, never a full-blob read) and reports damage -- digest-mismatch, size-mismatch, corrupt-sidecar, missing-blob, orphan-blob, orphan-tmp, foreign-file, stale-claim, corrupt-tombstone -- without touching anything. { repair: true } removes ONLY what it condemns (a damaged entry's blob and sidecar together, or a corrupt grave whose contents fail the parser); healthy entries survive byte-identical, a fresh push's in-flight .tmp is spared, and a stale claim is reported but never deleted (resolving it is crash recovery's job -- deleting a restorable claim would be data loss). The Report is { scanned, findings: [{ kind, id }], repaired: [{ kind, id }] }; id is the ref for ref-shaped damage (the store is the embedder's) and null for a foreign name -- verify never echoes an on-disk path. Damage is a FINDING; an I/O fault (a permission denial, a vanished layout dir) THROWS -- a walk error absorbed into a clean report would be fail-open. A condemnation is physical cleanup of already- broken data, not a lifecycle destruction, so it writes no grave (the SPEC.md 4.4 causes are pop/drop/clear/spent); a corrupt grave is simply removed.

Example

const report = await stash.verify();      // dry run: report only
await stash.verify({ repair: true });     // remove the condemned

References

stash.list

since 0.1.0 stable
stash.list(opts) -> Promise<Entry[]>

List every entry's metadata. Contents never appear; a listing that leaked blob bytes would defeat the point of refs as capabilities. Expired entries are filtered out by default; list({ includeExpired: true }) includes them. list only filters -- it never drops, so an expired entry still appears under includeExpired until a read verb or prune() reaps it.

Example

const entries = await stash.list();
entries.length;

References

stash.reconcilable

since 0.1.15 stable
stash.reconcilable() -> Promise<{ entries: Entry[], corrupt: string[] }>

The reconciliation-grade listing for anti-entropy (SPEC.md 4.4). list() is loud over a corrupt sidecar -- it fails the whole listing so damage is never silently dropped -- which is right for an audit but wrong for a sync loop: a single unreadable entry would abort enumeration and stall replication of every healthy one. reconcilable() returns { entries, corrupt } instead. entries is the healthy metadata a full-scan pass replicates (expired entries filtered, exactly as list()), and corrupt is the ref ids whose sidecars are too damaged to read. One rotten sidecar no longer blocks the sync of sound entries, and the damage is SURFACED, never swallowed -- route corrupt to verify({ repair: true }) to reap it. Structural layout damage (a foreign file in the store) and I/O faults still throw, as in list(): neither is a per-entry corruption with a ref to report. Contents never appear; a listing that leaked blob bytes would defeat the point of refs as capabilities.

Example

const { entries, corrupt } = await from.reconcilable();
for (const entry of entries) await to.store(entry, bytesFor(entry.id));
if (corrupt.length) console.warn("corrupt sidecars, run verify({ repair: true }):", corrupt.length);

References

stash.drop

since 0.1.0 stable
stash.drop(ref) -> Promise<boolean>

Delete an entry without reading it, and tombstone the id. Resolves false when the ref names nothing LIVE -- an absent entry is a fact, not a failure -- and true when a live entry was destroyed. A malformed ref still throws InvalidRef; replication input and typos both die at the whitelist. A corrupt entry is still removed -- drop deletes without reading, so a sidecar too damaged to parse never blocks cleanup; it carries no lifecycle event (there is no whole Entry to hand a listener). verify is the audit path that classifies the damage. Dropping an id the store never held still leaves a grave: that is how a tombstone propagates across replicas (SPEC.md 4.4) -- reconciliation drops each of the other node's grave ids, so a destroyed id is refused even on a node that never held it. Expiry is the one exception that leaves no grave (its terms travel with the entry, and every replica reaches the same deadline).

Example

await stash.drop(ref); // true -- gone

References

stash.clear

since 0.1.0 stable
stash.clear() -> Promise<number>

Drop everything; resolve to the number of entries destroyed.

Example

const destroyed = await stash.clear();

References

stash.prune

since 0.1.5 stable
stash.prune() -> Promise<number>

Destroy expired entries on demand; resolve to the count actually destroyed. Live entries are untouched. The count is real destructions, not the number of expired entries seen -- an entry that a concurrent drop removes first is not double-counted. Loud over corruption: it lists through the backend, whose verdict on a rotten stored entry surfaces rather than being skipped. sweepInterval calls this on a timer; without it, a store relies on the lazy read-path gate and whatever prune() its owner runs.

Example

const reaped = await stash.prune(); // number of expired entries destroyed

References

stash.close

since 0.1.5 stable
stash.close() -> Promise<void>

Stop the background sweep timer. Idempotent -- calling it again, or on a store that never armed a timer, is a no-op, never an error. It stops the janitor and only the janitor: a closed store still serves push, apply, and the rest. Stash also implements Symbol.asyncDispose as an alias, so await using stash = new Stash(...) clears the timer when the block exits, even on throw. Disposal is a real boundary: close() also awaits a sweep already in flight, so no background deletion lands after it resolves. Disposal is the real shutdown path -- the sweep timer is unref()'d so it never blocks process exit, but it keeps the Stash reachable until closed.

Example

const stash = new Stash({ backend, sweepInterval: "5m" });
try {
  await stash.push(data);
} finally {
  await stash.close();
}

References