Reproducibility
An initialize() instance reproduces from one number: its clock. That and an optional salt determine every value the instance produces. With no configuration, the clock defaults to the wall-clock instant of the initialize() call, so a run varies by process, has realistic dates, and replays from clock alone.
The clock is the entropy
const { T, Fabricator, context } = initialize();
// ...fabricate, and if something fails:
context.clock; // log thisThat clock instant is captured once, per initialize() call, and it is present in every leaf's trace. Consequently, every run produces different data, and passing the logged number (milliseconds from epoch) back reproduces that run exactly:
initialize(); // wall-clock instant of this call — the default
initialize({ clock: new Date(logged) }); // replay a recorded run
initialize({ clock: new Date("2024-06-01") }); // pinned to that instantThat is the whole reproducibility story for most instances. Nothing else needs configuring.
clock is also what "now" means to the library: T.date.past/T.date.future, and any custom .as(produce)/T.opaque producer that reads clock off its context, resolve against it. Pinning it therefore freezes both the dates and — because it sits in every trace — the rest of the run.
The one other setting is clock: "derived", which computes a fixed instant from the instance salt instead of the wall clock. It is drawn across the entire representable Date range, so a wildly implausible date is the expected outcome of that policy, not a bug. Reach for it only when you want replay that doesn't depend on wall-clock time at all.
clock composes the way fork/wrap already do: a captured or explicit instant is inherited as-is by a fork/wrap that doesn't override it. An explicit "derived" clock is the exception — fork({ salt: layer(...) }) without also overriding clock re-derives "now" from the composed salt.
Salt is optional
Most instances don't need a salt at all, and the section above is why: the clock already varies every run and already replays any of them.
A salt is not recommended because it adds a second value that must be captured and passed back alongside the clock to ensure reproduction. So reach for it only when it's useful, as it might be in these cases:
- Replay that doesn't depend on wall-clock time. Pair the salt with
clock: "derived"and the salt becomes the entire reproducibility unit — one value to record instead of two. The cost is an implausible "now", drawn across the whole representableDaterange. - One construction drawing from a different universe than the rest of the instance. For this, you'll usually want a per-construction salt, not an instance-wide one.
- Injecting a mixer from outside, without hardcoding it — that's what env-var salting below is for. It pins the salt only; an unpinned
clockstill varies, so replay still needscontext.clock.
const { T, Fabricator, salt } = initialize({ salt: "1234" });Env-var salting
If no salt is passed to initialize(), FABRICATOR_SALT supplies one. It never generates a value: unset, the salt stays empty.
FABRICATOR_SALT=repro-1234 bun testUseful for pinning a salt across a test run without touching any call site.
That caveat is why the conventional SEED and RANDOM_SEED names are not read. Using those is asking for a stable run, but a salt alone won't give them one (unless clock is set to be derived).Pinning fabricator's salt is a decision about fabricator specifically, so it is supplied by a fabricator-specific variable name, if needed.
Isolation between instances
Each initialize() call captures its own seeded generator and its own internal stream state. Independently initialized instances never interfere with each other — safe for parallel tests that each pick their own salt, and safe to run two unrelated fabricators in the same process without one perturbing the other's output.
Construction order
Every instance received from calling initialize() owns one internal construction counter. Each new Fabricator(...) takes the next ordinal from that counter, regardless of which module made the call, so two consecutive constructions never silently receive identical data.
Adding, removing, or reordering a construction shifts later constructions on the same instance. fork and wrap each create a fresh source with a fresh counter, which is how an integration or test can partition work more finely. Fields within one construction remain keyed by structural position, so schema edits do not shift their siblings.
Salting one construction
new Fabricator(schema, { salt: "fixed-user" });This overrides the salt for one build and changes nothing else. The construction still takes the next ordinal from the instance's counter and still inherits the instance's clock — everything an unsalted build does.
salt is not special here. new Fabricator(schema, options) accepts every slot of a .trace, and each one you name simply substitutes into that slot; salt is just one of them. So a salted build is an ordinary build that happens to draw from a different universe than its neighbors, not a build that has stepped outside the instance.
That means it is not a way to hold one construction still. It shifts, and is shifted by, the constructions around it exactly as any other does. When you want a build that is genuinely insulated — its own counter, unaffected by whatever else the instance builds — that is what fork is for:
const forked = instance.fork({ salt: "fixed-user" });
new forked.Fabricator(schema); // its own counters, its own universeA bare salt replaces the instance's salt on that Fabricator, so a construction written this way no longer varies when the surrounding run is re-salted. For an identity that should keep moving with the run — a schema's registered name, a test's own id — wrap it in layer(...) to compose instead:
import { layer } from "@ghostry/fabricator";
new Fabricator(schema, { salt: layer("some-identity") });This composes "some-identity" onto the instance's own salt ([...instance.salt, "some-identity"]) rather than replacing it outright — so the construction still varies when the instance is re-salted, the one thing a bare salt deliberately can't do. See Composing instead of replacing: layer(...) below for the full mechanism, and Deriving a related instance: fork for the same idea one level up — applied to a whole derived instance, rather than one construction at a time.
Deriving a related instance: fork
initialize() mints one instance. fork mints a related one, laid over the instance it was called on:
const base = initialize({ salt: "base" });
const A = base.fork({ salt: "A" });
A.salt; // ["A"] — replacedAnything the overlay names overrides; anything it omits inherits — algorithm, types, limits, clock, all included. A fork is a full peer of an initialize() return value: it has its own Fabricator, its own combinatorial/coverage, and its own fork, so forks compose.
This is the tool to reach for first. A fork is a configuration you hold as a value: pass it around, name it in a signature, keep it in a module, build through it whenever you like. Each of its receivers means exactly itself, so a call site says which configuration it draws from without you having to know what is open around it. wrap (below) trades that away for ambience, and is worth it only when the scope cannot reach the code that needs it. A captured wall-clock or explicit Date is inherited as-is, regardless of how the salt composes. The exception is an explicit clock: "derived": that sentinel re-derives from whatever salt the fork ends up with (see The clock is the entropy below), so fork({ salt: layer(...) }) without also overriding clock gets a different "derived" clock than its base.
By default a fork's salt replaces the base's outright, as is the case everywhere in this library. To compose onto the base's salt instead, reach for layer(...):
const forked = base.fork({ salt: layer("my-fork") });
forked.salt; // ["base", "my-fork"] — composedSee Composing instead of replacing: layer(...) below — the same helper works identically at three levels: fork, wrap (below), and a single new Fabricator(schema, { salt }) call.
Composing instead of replacing: layer(...)
A bare salt always means the same thing in this library: replace whatever salt applied before, entirely. layer(salt) marks the opposite intent at the call site: append onto whatever salt is already in effect, rather than discard it.
import { initialize, layer } from "@ghostry/fabricator";
const base = initialize({ salt: "base" });
base.fork({ salt: "a" }).salt; // ["a"] — replaced
base.fork({ salt: layer("a") }).salt; // ["base", "a"] — composedlayer works identically everywhere a salt is accepted against some base:
fork({ salt: layer(...) })— composes onto the instance's own salt.wrap({ salt: layer(...) })— composes onto the instance it was called on, exactly asforkdoes (below) — so nesting accumulates through the enclosingscope.new Fabricator(schema, { salt: layer(...) })— composes onto the instance's salt (or a visiblewrapframe's, if the construction happens inside one), for a single construction rather than a whole derived instance.
Reach for layer(...) any time an identity — a test's own name (which the harness integration layers for you), a schema's registered name — should still vary when the surrounding run is re-salted. A bare salt is for the opposite case: a fixture that must stay exactly the same no matter what.
When you cannot thread the scope: wrap
wrap makes a fork ambient for a block of code, so ordinary calls inside it pick it up with nothing threaded through.
Reach for it when the scope cannot reasonably reach the code that needs it: a test body or callback you don't own, a deep call stack you won't thread a parameter through, or call sites already written against a destructured Fabricator. In those cases ambience is sugar for passing the scope. Everywhere else — where the block can simply use the scope it was handed, or a fork held as a value — the explicit receiver is easier to read and should be preferred.
The price of ambience is that, inside a wrap, a receiver no longer tells you which configuration a call draws from. That is not a wart to work around; it is the whole of what a wrap does:
const { T, Fabricator, combinatorial, wrap } = initialize({ salt: "base" });
wrap({ salt: layer("a") }, (scope) => {
new Fabricator(T.number).fabricate(); // picks up the wrap automatically
new scope.Fabricator(T.number).fabricate(); // identical source, used explicitly
[...combinatorial(T.boolean)]; // also picks up the wrap
});wrap governs constructions on the instance it was called on and on that instance's ancestors — see Ambience governs the receiver and its ancestors. The block also receives the fork directly, as scope: reach for it when explicit is clearer, or when work needs to outlive the block — a callback stored and invoked later has no frame around it, so only an explicit receiver still carries the wrap's configuration.
A wrap's overlay lays over the instance it was called on, exactly as fork's does, regardless of whether or not another wrap is already open around the call. So nesting accumulates when the inner wrap goes through the enclosing scope, and restates when it goes through a receiver bound outside the block:
wrap({ salt: layer("a") }, (scope) => {
scope.wrap({ salt: layer("b") }, (inner) => {
inner.salt; // [...base.salt, "a", "b"] — composed
});
wrap({ salt: layer("b") }, (inner) => {
inner.salt; // [...base.salt, "b"] — restated
});
});The second one is worth knowing about, because a destructured wrap is permanently bound to the instance it came from. When you want to compose onto whatever is in effect and do not have the enclosing scope in hand, go through context.scope():
wrap({ salt: layer("a") }, () => {
context.scope().wrap({ salt: layer("b") }, (inner) => {
inner.salt; // [...base.salt, "a", "b"]
});
});scope is a function, not a property, and that is what makes lifting it out of context safe — capturing it captures the lookup rather than one answer, so a destructured scope still resolves live. The rest are getters, so destructuring one (or spreading the object) freezes it; that only ever costs you a stale value, where a frozen scope would have cost you correct-looking code laying over the wrong base. Destructuring context itself is fine either way, and is how these examples get it.
Ambience governs the receiver and its ancestors
A wrap governs constructions on the instance it was called on, and on that instance's ancestors — never on its descendants, its siblings, or another initialize(). The innermost governing wrap wins; with none, an instance draws its own configuration.
const A = base.fork({ salt: layer("A") });
const B = base.fork({ salt: layer("B") });
B.wrap({ salt: layer("b") }, () => {
new B.Fabricator(T.number); // [...base.salt, "B", "b"] — the receiver
new base.Fabricator(T.number); // [...base.salt, "B", "b"] — an ancestor
new A.Fabricator(T.number); // [...base.salt, "A"] — a sibling
});Note the middle line: the receiver says base, and the configuration is B's frame. A destructured Fabricator is bound to the instance it came from and governed exactly when that instance is, which is what makes it work without plumbing, and exactly why a receiver inside a wrap does not tell you what it draws from. If that matters at a given call site, build through a fork instead of entering a wrap.
A fork of the receiver — created before the wrap, or during it — draws its own configuration. To carry a wrap's layer onto a derived instance, fork the scope — scope, or base.context.scope() when it is not in hand:
base.wrap({ salt: layer("a") }, (scope) => {
const C = scope.fork({ salt: layer("C") });
new C.Fabricator(T.number); // [...base.salt, "a", "C"]
const D = base.fork({ salt: layer("C") });
new D.Fabricator(T.number); // [...base.salt, "C"] — no wrap layer
});Governance follows the line, not the nesting. A wrap on a descendant opened inside a wrap on its ancestor governs that ancestor, while the outer wrap's scope — itself a descendant of base, on another branch — keeps its own configuration:
base.wrap({ salt: layer("a") }, (scope) => {
B.wrap({ salt: layer("b") }, () => {
new base.Fabricator(T.number); // [...base.salt, "B", "b"] — an ancestor of B
new scope.Fabricator(T.number); // [...base.salt, "a"] — not an ancestor of B
});
});context.depth counts the wraps governing that instance, so A.context.depth is 0 inside B's wrap while B.context.depth is 1. A wrap's own scope is a descendant of the receiver, so scope.context.depth is 0 — it already holds the wrap's configuration as its own.
Which instance a lineage started from carries no special weight here. A frame is keyed on the instance wrap was called on, whatever its position — so a caller who forks an application-wide configuration and treats that fork as their own root, destructuring from it and never naming the original again, gets the same ambience as anyone else. Being the instance initialize() returned is an accident of where that call happened, not a statement about how an instance is used. Forks of that treated-as-root instance are not reached; they draw their own configuration.
Lineage identity is the instance's, not the carrier's: a.root === b.root answers "same lineage?", and two separate initialize() calls stay mutually invisible even when handed the same stack. Passing initialize({ stack }) chooses a carrier and nothing else.
Async blocks
block may be async, and the ambient frame survives await:
await wrap({ salt: layer("a") }, async (scope) => {
new Fabricator(T.number).fabricate(); // wrapped
await something();
new Fabricator(T.number).fabricate(); // still wrapped
new scope.Fabricator(T.number).fabricate(); // wrapped, explicitly
});Two wraps running concurrently stay isolated from each other too — neither sees the other's salt, and neither leaks past its own block.
This works because the ambient frame is carried by AsyncLocalStorage wherever node:async_hooks exists, which covers Node, Bun, and Deno. Nothing needs configuring; the right carrier is chosen when the package is imported.
Runtime support
A runtime without node:async_hooks — a browser bundle, most likely — falls back to a synchronous carrier whose frame cannot outlive the block's first await. Rather than silently resolving a later build against the base instance, wrap rejects the block outright:
wrap({ salt: layer("a") }, async () => {}); // throws SynchronousStackErrorIt throws on any async block, including one that only ever uses scope because wrap has no way to know whether something later will read the ambient frame. scope is still the escape hatch there: it's an ordinary Instance, unaffected by whether a frame is on the stack, so a synchronous block that hands scope to async work keeps the wrap's configuration with no time limit.
To use async blocks on such a runtime, you would need to supply an external carrier that is able to handle async frames:
initialize({ stack: someAsyncCarrier });context
instance.context reads whatever configuration is in effect right now — the active wrap frame's, or the instance's own outside any wrap:
wrap({ salt: layer("a") }, () => {
context.salt; // [...base.salt, "a"] — same as scope.salt, read ambiently
});It's a live view, not a snapshot: a context reference held onto before a wrap still reflects it while active, and reverts once the wrap ends.
Which field gets which randomness
Two different questions get asked about reproducibility, and they have different answers:
"Does skipping a field change what other fields produce?" No. A field's stream is derived from its own structural path within the schema — its field name, its position in a tuple, and so on — never from how many fields were dispatched before it. Adding, removing, reordering, or renaming a field changes nothing about any other field's own randomness.
"Does skipping a draw at fabricate-time change other fields?" Also no. Once a field has its own private stream, calling .fabricate() conditionally (e.g. an omittable field that only draws its inner value when its presence roll says "present") only affects that field's own position in its own stream on the next call. It can't touch any sibling field, because by that point every field already has an independent generator instance. This is why object.omittable/object.optional/nullable/undefinable all skip their wrapped value's draw entirely when the roll doesn't need it — it's free, and it costs nothing in reproducibility.
The practical upshot: schema edits never perturb unrelated fields, and calling .fabricate() fewer times within an already-built Fabricator never does either.
.trace
Every built Fabricator records where its randomness comes from, on a required trace property — including nodes that never draw of their own (a bare T.object, T.always). That is what makes a nested subtree replayable.
const NameField = new Fabricator(T.string.whereby({ length: { max: 20 } }));
NameField.fabricate();
NameField.trace;
// { salt: ["default-salt"], clock: 1234567890000, path: [], kind: "string", ordinal: 0 }The result is the fixed tuple that field's stream was hashed from — salt is the instance's salt (not anything derived per field), clock is the resolved instant this construction resolves "now" against (see The clock is the entropy below — every field's stream depends on it, not just a T.date field's), path is the field's own structural position within its construction (empty here since this Fabricator is the whole construction, but e.g. ["address", "city"] for a nested field built as part of a larger object), and ordinal is which construction on that source this one is (null for a combinatorial/coverage build, which takes no ordinal).
new Fabricator(schema, trace) reproduces exactly what that schema produced as a nested node of a larger construction: pass the node's own schema plus node.trace. The same sequence of .fabricate() calls yields the same values; the trace does not jump to a later draw.
Three values are not a function of the node's own stream. Replay the parent instead:
- a
.refine()compute field —.fabricate()throws without the parent object - a
recursive.selfnode —.fabricate()throws without the enclosingT.recursive - an
.override()[Fixed]field — replaying the field's own schema yields the drawn value the parent discarded
Bring your own PRNG
initialize({ salt: "1234", algorithm: (seed) => myPrng(seed) });algorithm is a factory: given a seed, it returns a () => number in [0, 1) — a drop-in replacement for Math.random. Defaults to a built-in sfc32 generator.
The seed it receives is not your salt. It's the full encoded description of one leaf — salt, clock, structural path, kind, and ordinal — of which the salt is a single slot. That's why every field in every construction gets its own independent stream from one instance, and why a custom algorithm inherits that isolation for free: it's called once per leaf, with a different seed each time.
