Releases: blamejs/stash
Release list
v2.0.1
A documentation-correctness release. No API, behavior, error, or on-disk
format changes: a 2.0.0 store opens under 2.0.1 unchanged and every verb does
exactly what it did. What changes is the standard the documented examples are
held to. The @example blocks in the source comments generate the API
reference and ship inside the tarball, so they are code a reader copies --
but nothing had ever run them. They were compiled and the result discarded,
which catches a syntax error and nothing else: an example calling a method
that has been renamed, or passing an option the code stopped accepting,
parses perfectly and is still wrong. All of them now execute on every build,
each in its own process, and the build fails if one does not. Turning that on
found a broken example on the first run, fixed here. Examples are also now
resolved through the package's published exports map, so a snippet
importing a subpath the package does not publish fails the build rather than
the reader's terminal.
Added
- The build executes every
@examplein the published documentation and
fails if one of them does not run. Each example runs as a real module in
its own process and counts as verified only when that process exits cleanly
-- an example that hands its work to a test runner, as the
backend-conformance snippet does, is judged on the assertions rather than
on the import returning. Import specifiers resolve through this package's
ownexportsmap, so an example naming@blamejs/stash/backends/diskis
checked against what the package actually publishes, and an example whose
body is only prose or only imports is rejected for showing no call. There
is no way to opt an example out. CONTRIBUTING.md describes what an example
may assume.
Fixed
- The example for
close()constructed aStashwithout importing it, so
copying it produced aReferenceError. It now carries the import the other
constructor examples do. This was the only documented example that did not
run; the rest were confirmed correct by executing them.
v2.0.0
A small, sharp-edged major, and one memory-disclosure fix. This release ships
WITHOUT a preceding deprecation minor -- an exception to the post-1.0
commitment, recorded with its reason in MIGRATING.md, so read the recipe
rather than relying on having been warned at runtime. Everything the store
does is otherwise unchanged: the verb set, the on-disk format, the ref
format, expiry and read budgets, replication, digest agility, and the CLI all
behave exactly as they did in 1.x. What changes is how a few failures report
themselves. Rejecting an entry whose metadata is too large for a sidecar is a
judgement about stored content, not about a caller's argument -- so it now
throws IntegrityError with code EINTEGRITY, the same verdict the read
side of that identical bound has always used, and the same class every other
content rejection uses. Previously it threw a bare TypeError with no code
at all, labelled push: even when store() was the caller, which left a
replication path reporting a verb the caller never invoked and an error the
documented err.code contract could not describe. Separately,
runBackendConformance now requires the name its published { name, create() } contract has always specified and labels every registered case
with it; certifying two backends in one run previously produced two identical
sets of test titles with nothing to distinguish a failure. Upgrading is two
mechanical edits at most, both listed in MIGRATING.md, and neither touches
stored data: no migration, no re-write, no format change. A 1.x store opens
under 2.0 unchanged.
Changed
- The Node floor moves from 24.18.0 to 24.19.0. It remains a floor and
not a ceiling -- any newer 24.x, and later majors per the LTS calendar, are
supported -- and nothing the store does depends on a capability that first
shipped in 24.19; the patch level is a security-currency floor, kept
current at the major boundary where raising it is not a surprise. Consumers
pinned below 24.19.0 upgrade Node; nothing else changes. - Fuzzing runs the same targets under jazzer.js directly instead of through
ClusterFuzzLite, whose action cannot build a JavaScript project at any
sanitizer setting. Pull requests touchingsrc/get a burst against every
target and a scheduled run fuzzes longer each night, with a crash's
reproducer kept as a run artifact. The seed-corpus verdict check still runs
with plain Node and no engine. The engine is installed only inside the
fuzzing job and outside the checkout, so the zero-dependency posture --
runtime and dev -- is unchanged. - An entry whose
metais too large for a sidecar is now rejected with
IntegrityError(codeEINTEGRITY) instead ofTypeError. The bound is
enforced by the backend write that serves bothpush()andstore(), and
a replicated entry is untrusted stored input, so the rejection is a content
verdict carrying a code you can branch on -- matching how the read side of
that same bound has always reported an over-large sidecar. If you catch
this case, catchIntegrityError(or branch onerr.code === 'EINTEGRITY') rather thanTypeError. The message no longer names a verb,
because the line cannot tell which one called it. runBackendConformance(factory, ...)now requiresfactory.nameto be a
non-empty string, and prefixes every case it registers with it. The{ name, create() }contract already specifiedname, and the type check
already named it, but nothing enforced or used it -- so a third-party
author certifying two backends in one run got two byte-identical sets of
test titles and a failure that named no backend. If you were passing only
{ create }, add aname.v1.xis superseded and will NOT receive security patches, withdrawing the
24-month windowv1.0announced would open when its successor shipped.
v1.xhad no known consumers andv2.0is a two-edit upgrade with no
on-disk format change, so the line is retired rather than serviced. The
memory-disclosure fix below therefore ships only inv2.x; LTS-CALENDAR.md
and SECURITY.md record both facts. If you are onv1.xand cannot upgrade,
open a security advisory and the position will be revisited.- A
Uint8Arraycreated in another realm -- avmcontext, a second module
loader -- is now accepted as apush()orstore()source. It was already
accepted when yielded as a chunk, so the two paths disagreed about the same
value, and the documented source set namesUint8Arraywith no realm
qualifier. An 8-bit unsigned view remains the only typed array that may
pass: aUint16Arrayis still refused, because copying one would silently
store a single byte per element.
Fixed
- A source that misreported its own length could make the store record bytes
the caller never supplied.lengthon a typed array is an ordinary
property, so aUint8Arraysubclass holding two bytes but reporting 512
caused the copy to allocate 512 bytes from Node's shared pool and write
only the two real ones -- padding the entry with whatever the pool last
held, and certifying a size and digest over content that was never handed
over. Sources are now copied by their real byte length, which no property
can forge. A store embedded alongside other tenants was the exposure; the
affected copy also chargedmaxSizeandmaxTotalfor the phantom bytes. - The shared source check no longer names a verb.
push()andstore()both
reach it, so its message saidpush:even whenstore()was the caller --
the same defect the sidecar-size verdict fixes, one function above it. - The temp-file grace period was documented as making
verifyreport an
in-flight.tmpwithout repairing it. It reports neither, and it does
discard one aged past the grace under{ repair: true }. consumeRead's sidecar rewrite was described as atomic. It is deliberately
an in-place rewrite through a single descriptor -- which is what prevents a
concurrentdrop()from being undone -- and a crash mid-write is caught by
the next read, which rejects the short sidecar.clear()resolves to the number of live entries destroyed. Entries that
had already expired are reaped as well but are not counted, and each live
destruction leaves a'clear'grave visible throughtombstones(). None
of this was stated.- The memory backend's tombstone map stores
idalongsidedestroyedAtand
cause, and consumers read thatid; the field comment omitted it,
inviting an edit that would have broken replication. - Constructor option errors now all carry the
new Stash:prefix. Seven of
nine were missing it, so the same option could report two
differently-shaped messages depending on which check rejected it.
v1.0.0
The store is feature-complete and the surface it presents is now a contract.
Bytes in, a random-capability ref out, bytes out once and they're gone: the
git-stash verb set, per-entry expiry and read budgets, streaming size and
store-wide limits, integrity verification and audit, tombstone-based
replication, digest agility, and the operational CLI are all shipped and
frozen against breaking change. From 1.0, a breaking change ships a new major
preceded by a deprecation warning at least one minor ahead, and each major
carries a 24-month security-patch window after its successor ships. Nothing
about the code, the API, the on-disk format, or any documented guarantee
changed in this release -- 1.0 is the final 0.1.x store with a commitment
attached, so consumers upgrade with npm install @blamejs/stash@1 and no
code changes. The one load-bearing property is unchanged and now permanent:
there is nowhere in the store to put a key, so it can never decrypt, and no
operator can be compelled to.
Changed
- The public API surface is declared stable under semantic versioning. Every
verb (push,apply,pop,show,has,list,reconcilable,
stats,tombstones,store,drop,clear,verify,prune,
close), every constructor option, every typed error code, the ref format,
and the on-disk sidecar format are covered: a breaking change ships a new
major, preceded by a deprecation warning at least one minor ahead and a
migration recipe. See MIGRATING.md and LTS-CALENDAR.md. - The support policy takes effect: each major receives 24 months of
security-only patches beginning when its successor ships, on the
zero-dependency posture (no third-party CVE lane -- a vulnerability is in
this library's code or in Node itself). The Node floor is 24.18.0. - The published package now includes the support-window calendar
(LTS-CALENDAR.md), the vulnerability-disclosure policy (SECURITY.md), and
the roadmap (ROADMAP.md) alongside the changelog, migration guide, and
spec, so every cross-reference in the packaged README resolves for a
consumer reading the extracted tarball rather than pointing at a file the
package omits.
v0.1.18
A sweep of the tarball's prose -- the source comments, the threat model, the
architecture and spec docs -- to remove development-process voice in favor of
plain descriptions of the store's behavior. Comments that named internal
build artifacts a reader cannot resolve (numbered internal-rule and
fragile-area references), narrated when a feature was added rather than what
it does (milestone and version tags like (M4) or implemented in M2), or
described planned-but-not-yet work now state the current, shipped behavior
directly. The code, the API surface, the on-disk format, and every documented
guarantee are unchanged -- this release only makes the shipped explanations
read for someone encountering the store for the first time.
Changed
- Source comments and the operator-facing docs (SPEC.md, THREAT-MODEL.md,
ARCHITECTURE.md, README.md, CONTRIBUTING.md) now describe the store's
behavior and how to use it rather than how it was built: internal
build-order and milestone tags, references to internal development
conventions a reader cannot resolve, and phrasing that narrated
version-to-version change were replaced with plain statements of the
current shipped behavior. The threat model states each defense as
implemented fact without per-defense build-provenance tags; the
architecture doc presents the shipped surface as a capability list. No API,
behavior, format, or guarantee changed -- prose only.
v0.1.17
The default 'restore' policy has always kept a failed read's entry for
retry. 'burn' is the opt-in that destroys a read entry on the assumption
that a read attempt may have observed its bytes -- but crash recovery applied
it even to a claim whose process died before any byte was read, turning a
crash into silent data destruction that burn's own rationale does not
justify. onPopFailure now governs only the LIVE read path -- a read that
fails mid-drain, resolved in-process by its own handler. Crash recovery of a
stale orphan always RESTORES it and never burns, since a crashed process
observed nothing recovery can confirm and handing a once-only read back for a
retry is safer than destroying it. The store's wall-clock posture -- and its
behavior under a clock step in either direction -- is now written down.
Changed
- The store's clock posture is now documented (SPEC.md 7.2): every time-based
decision -- expiry, claim-lease freshness, tombstone pruning -- reads the
wall clock, because each must survive a restart and, for expiry and
pruning, agree across replicas. Expiry and pruning are deterministic (an
absoluteexpiresAt/destroyedAt, not skew-sensitive); a live reader's
claim is immune to a clock step; and a backward step only defers a
destruction the push-time terms already permit -- it never destroys an
entry early nor lets one outlive its terms. - The README documents materializing an entry to a file in a single,
digest-verified copy (pipeline(await stash.apply(ref), createWriteStream(dest))). The store deliberately offers no
materializeTomethod: a store-side copy would give up the digest-verified
read, cross the store's write-scope boundary, and work only on a filesystem
backend, where piping the verified stream is already single-copy and
backend-agnostic.
Fixed
- Under
onPopFailure: 'burn', crash recovery destroyed a stale claim left
by a crashed process even when no byte had been read, turning a crash
before any read into silent data destruction.onPopFailurenow applies
only to a LIVE read that fails mid-drain (resolved in-process); crash
recovery of a stale orphan always RESTORES it and never burns, even under
'burn'. The entry comes back for a retry and no tombstone is written. The
default'restore'policy is unchanged.
v0.1.16
The SPEC.md 9 backend interface is now a public extension point with a stated
stability discipline, not an undocumented internal seam: any object
implementing its method set is a first-class backend for new Stash({ backend }), and the contract is executable. The new @blamejs/stash/conformance
subpath exports runBackendConformance(factory, { test }) -- the behavioral
suite the in-tree memory and disk backends pass -- so a store on a filesystem
this library does not ship certifies interchangeability against the same
cases without cloning the repository. It imports no test runner, so a backend
author wires their own. Alongside it, a prototype-key-confusion hole
(CWE-1321) is closed in two lookups keyed by untrusted strings: a stored
digest's algorithm prefix and a CLI subcommand token could name an inherited
Object.prototype member and resolve to a phantom the registry never defined
-- both now gate membership on Object.hasOwn, and a source-wide detector
keeps the class from reappearing. Documentation gains a CommonJS consumption
guarantee (pinned by a fixture), a soundness criterion for the onPopFailure: 'burn' trade, the rationale behind the Node floor, and the memory backend's
claim semantics without a filesystem.
Added
@blamejs/stash/conformance-- a shippable, zero-dependency,
test-runner-agnostic backend conformance harness.
runBackendConformance(factory, { test, assert? })registers the SPEC.md 9
behavioral suite against your backend factory, driving the shippedStash
consumer path (push / apply / pop / store) and asserting the frozen error
verdicts (ENOREF, ECLAIMED, E2BIG, EFULL), so a third-party backend
certifies itself against the same cases the in-tree backends pass without
cloning this repository. It imports no test runner -- you wire your own
(node:testor otherwise) -- and the in-tree conformance run composes the
identical harness against both backends, so there is one definition of the
shared cases.- SPEC.md section 9 now declares the backend interface a stable, spec-first
contract: its method surface, its semantics (claim atomicity, consumeRead
atomicity, tombstone first-write-wins, digest verification on read), and
its error expectations change only with a spec change. Pre-1.0 that
contract is stable within a version line.
Changed
- CommonJS consumers can
require()the main entry and both backend subpaths
on the Node floor -- the module graph has no top-level await and imports
only a synchronous JSON module -- now documented in the README and pinned
by a spawned .cjs fixture against a future regression. - The
onPopFailure: 'burn'trade has a stated soundness criterion (SPEC.md
6, SECURITY.md):'restore'(the default) keeps a failed pop's entry
intact for retry and is safe for irreplaceable bytes, while'burn'
reinstates partial-delivery data loss and is sound only when a partial read
must never be retried and the loss is acceptable. - The
>=24.18.0Node floor's rationale is recorded (SPEC.md 2,
LTS-CALENDAR.md): the capabilities the store relies on -- the stable
permission model, explicit resource management, require(esm) -- arrived at
the Node 24 major; the patch level is a security-currency floor, not a
feature dependency. - The MemoryBackend
@moduleblock documents its claim semantics without a
filesystem -- the synchronous Map move as the atomicity two concurrent pops
race on, aclaimedAtstamp as the disk mtime analogue, and
process-lifetime claims with no cross-process crash recovery -- and the
bounded-insert capacity gate records its two-pass (prune then stats) design
as an irreducible invariant.
Security
- CWE-1321 (prototype-key confusion): a stored digest whose algorithm prefix,
or a CLI subcommand token, named an inherited Object.prototype member
(constructor,__proto__,toString,valueOf) resolved to that member
instead of a registry miss. The phantom defeated the digest read's
algoOf(digest) ?? defaultfallback -- turning a self-describing read into
a crash on an algorithm the store cannot construct -- and slipped past the
CLI's unknown-command guard so an inherited name was dispatched as a known
command. Both lookups now gate membership onObject.hasOwn, so only a
defined algorithm or command ever resolves and a non-registry key is always
a clean miss; a source-wide detector fails the build on any reintroduction
of the inline membership shape.
v0.1.15
A batch of replication- and recovery-correctness fixes. reconcilable() is a
new resilient query for the anti-entropy loop: list() stays loud on a
corrupt sidecar (right for an audit), but one damaged entry no longer halts
the replication of every healthy one -- reconcilable() returns the healthy
entries and reports the corrupt ids separately, so a sync keeps flowing and
surfaces the damage instead of stopping. store() now reconciles on byte
identity rather than the algorithm-tagged digest string, so two stores
holding the same bytes under different digest algorithms (a mixed-algorithm
topology, first-class since digest agility) reconcile idempotently instead of
throwing a spurious integrity conflict -- verified byte-for-byte, so
genuinely different bytes and a lying replica still fail closed, and the
reconcile no longer rejects an already-present entry just because the
receiving store's limit is now smaller. The disk backend's blob rename -- the
one mutation rename that was not retried -- now rides out a transient
filesystem fault (a briefly-held handle on Windows) exactly as the sidecar
and claim renames already do, so a legitimate push is not lost. And crash
recovery and the claimed-read path are hardened throughout: a live reader's
claim is guarded from the moment acquisition begins (so a forward wall-clock
step can never hand a once-only entry to a second reader, nor destroy a read
mid-drain), a claim orphaned by a faulted resolution is always reclaimed
rather than stranded, a crash-corrupted sidecar on a claimed entry no longer
wedges every operation, and a replicated entry with an exhausted read budget
is rejected. Documentation was also swept for stale single-algorithm digest
references left over from digest agility.
Added
reconcilable()-- a resilient reconciliation query returning{ entries, corrupt }: the healthy entries to replicate plus the ids of entries whose
sidecars are too damaged to read. It is the source read for the documented
anti-entropy loop, so one corrupt sidecar can no longer halt replication of
the healthy entries;list()remains loud over corruption (an audit must
be), and the corrupt ids are surfaced forverify({ repair: true }), never
silently skipped.
Fixed
store()reconciles a replicated entry on BYTE IDENTITY, not the
algorithm-tagged digest string. Two stores holding the same bytes under
different digest algorithms carried different"<algo>:<hex>"strings, so
an identical-bytes replica was rejected as a digest conflict and could
never reconcile. It now settles the idempotent/conflict verdict on the
bytes -- identical bytes reconcile as a no-op, genuinely different bytes
still fail closed withIntegrityError, and a lying replica is caught --
nothing is written and no id is resurrected.- A same-bytes duplicate under a different digest algorithm now reconciles
idempotently even when the receiving store'smaxSizeis smaller than the
entry -- a lowered limit, or a replica with a tighter limit, no longer
wedges mixed-algorithm anti-entropy on a spuriousSizeExceeded. A
reconcile writes nothing, so it bounds its byte-identity re-hash by the
already-stored entry's own size; an oversized replica under an existing id
is reported as anIntegrityErrorbyte conflict, never a limit error. - The claimed-read path (
popand budgetedapply) is hardened around crash
recovery, which never age-reclaims a claim a live reader holds -- only
orphan claims from a crashed prior run. The live-holder guard now protects
a claim across its whole life: from the moment acquisition begins, through
the drain, until it resolves. So a forward wall-clock step past
claimTimeoutcan neither resurrect a once-only entry for a second reader
during acquisition nor reclaim/burn the read a consumer is still mid-drain
on; and a claim orphaned by a faulted resolution (a restore, commit, or
burn that throws) is always reclaimed by recovery on the next operation,
rather than strandedECLAIMEDuntil the process restarts. The guard is
shared per store (keyed by backend identity), so even twoStashinstances
constructed over one store -- single-writer-per-root is the contract, but
the guard holds regardless -- never age-reclaim each other's live reads. - A crash during a budgeted read that leaves a claimed entry with a corrupt
sidecar no longer wedges the entire store. Because crash recovery runs
before every operation, one such entry previously made every call --
push,apply,pop,drop,list,stats-- fail with an integrity
error, with no way back. Recovery now reaps the unreadable entry; a genuine
layout fault still fails loudly. store()rejects a replicated budgeted entry whose read budget is already
exhausted (readsLeft0) as malformed, instead of accepting it and then
throwing on the first read: a live budgeted entry always has at least one
read remaining, since the read that spends the last credit destroys the
entry.- Disk backend: the blob
.tmp->final rename now retries a transient
filesystem fault (a briefly-lingering handle, e.g. on Windows under
contention) instead of failing an otherwise-valid push and destroying the
already-streamed bytes -- the one mutation rename that was not yet wrapped
in the transient-fault retry every sibling rename uses. - Documentation and code comments no longer describe the integrity digest as
fixed to sha256: prose and@specreferences now reflect the
construct-time algorithm choice (sha256 by default) and the full FIPS
coverage (180-4 for sha2, 202 for sha3/shake).
v0.1.14
The store gains its first executable entry point. npx @blamejs/stash <command> (or the stashjs command from a global install) exposes the query
and maintenance verbs: verify (audit for damage, --repair to remove
condemned entries, orphans, and corrupt graves), stats, prune, list
(--include-expired to reveal lapsed entries), tombstones, and has <ref>. Add --json to any command for a machine-readable document, for cron
and scripting. The root comes from --root <dir>, else $STASH_ROOT, else
./.stash; a missing root is refused rather than conjured into an empty
store from a typo. The CLI composes only already-shipped verbs -- it never
moves bytes (no push/apply/pop/store) and never destroys by ref (no
drop/clear), so it hands out no capability and streams no blob. It runs under
node --permission with read/write scoped to the root exactly as the library
does, fails closed with stable exit codes, and never echoes a ref, a metadata
value, or a filesystem path in an error. Point it at a stash whose owning
process is stopped, or a cold-standby replica: like every access to a disk
root, it is single-writer, and every command except verify runs the
crash-recovery scan first.
Added
- The
stashjsCLI (npx @blamejs/stash <command>):verify [--repair],
stats,prune,list [--include-expired],tombstones, andhas <ref>, each a thin adapter over the matching Stash verb.--jsonon any
command emits the raw object for scripting; the default is a human table
(which omits opaque per-entry metadata). The root is taken from--root,
$STASH_ROOT, or./.stash, and must already exist. Exit codes are stable
and distinct: clean, damage-found, usage error, malformed ref, integrity
fault, or I/O fault. It exposes only the query and maintenance verbs --
never the bytes-moving or destroy-by-ref verbs -- and holds the library's
permission-model and capability-free-error posture.
v0.1.13
On a platform without O_NOFOLLOW (Windows), the disk backend cannot refuse a
symlink at the open itself, so after opening a blob or sidecar it
cross-checks the descriptor's identity against a no-follow lookup of the name
-- catching a symlink traversed at open or a name swapped for a different
object afterward. That check keyed on device + inode alone. Windows
synthesizes the inode from the NTFS file index and can transiently report 0
for a file under heavy parallel I/O, so two distinct in-root files could both
read as inode 0 and be mistaken for the same object -- a fail-open in the
swap guard. The identity comparison now also requires size and creation time
to match. Because the new terms are ANDed on, the check only ever becomes
more selective: it never accepts a swap it rejected before, and an untampered
read (whose open descriptor and name resolve to the same inode) still
matches, since size and creation time agree for one object. This is
defense-in-depth -- planting a symlink in the store root already requires a
privilege the recommended permission sandbox denies -- but a security guard
that silently passes on an identity collision is a real gap, now closed. No
API change.
Fixed
- Disk backend, platforms without
O_NOFOLLOW(Windows): the post-open
symlink-swap guard compared only device + inode, so two distinct files that
transiently reported inode 0 (an NTFS-file-index quirk under heavy parallel
I/O) could be mistaken for one object -- a fail-open. The file-identity
comparison now also requires size and creation time to agree. The terms are
ANDed, so the check is strictly more selective and cannot vouch for a
swapped object it previously rejected; an untampered read still matches.
The same identity check is now the single choke point behind both the
read-path swap guard and the crash-recovery interrupted-claim check.
v0.1.12
The integrity hash is now a construct-time choice: new Stash({ backend,
digest }) selects sha256 (the default, unchanged), sha512, sha3-256,
sha3-512, or shake256 -- all node:crypto builtins, so the zero-dependency
rule holds. This is crypto-agnosticism for INTEGRITY, not confidentiality:
there is still no key and no cipher anywhere in the tree. The stored digest
is self-describing ("algo:hex"), so a read -- and verify() -- hashes with the
algorithm the entry was WRITTEN with, never a global assumption. A store may
therefore hold entries under different algorithms (the option changed over
the store's life, or store() replicated an entry that carried its own) and
each still verifies. The construct-time option sets the algorithm for new
pushes only; omitting it keeps sha256, so every existing store is
byte-identical. An unknown algorithm is a config-time TypeError; a stored
digest with an unknown algorithm or the wrong hex length is an
IntegrityError.
Added
digestconstructor option -- the integrity-hash algorithm for new writes:
sha256(default),sha512,sha3-256,sha3-512, orshake256. sha2
is FIPS 180-4; sha3 and shake are FIPS 202;shake256's output is pinned
to 64 bytes. The stored digest is self-describing ("<algo>:<hex>"), so
apply,pop, andverify()hash each entry with its own algorithm -- a
store may mix algorithms and every entry still verifies, andstore()
replicates an entry with its algorithm intact. The default is unchanged, so
an omitted option leaves every existing store byte-identical. This stays
inside SPEC.md section 1: still no key, no cipher -- the digest verifies
integrity, never confidentiality.