Diagnostics
Gaffer surfaces diagnostics while validating and running projections: they appear on the gaffer dev summary, in your editor’s Problems panel, and on FeedResult.diagnostics in the testing library. Every diagnostic has a stable <class>.<subject>.<detail> code.
quirk.*reproduces a KurrentDB engine bug so local runs match production. A quirk fires wheneverquirks_versionis unset (the default, which reproduces every known quirk), or is set to a release earlier than its Fixed in version. Targeting that release or later turns it off.usage.*flags something about your own projection code: an unsupported construct, a no-op under an engine version, or a deprecation.
Severity is Error (no correct form: it throws or is unsupported), Warning (runs but produces a wrong or surprising result), or Information (works, but noteworthy).
Quirks
Section titled “Quirks”Reproduced KurrentDB engine bugs, gated by quirks_version.
quirk.linkStreamTo.outOfBoundsParameters
Section titled “quirk.linkStreamTo.outOfBoundsParameters”Severity: Error · Fixed in: not yet shipped upstream
linkStreamTo(stream, link, metadata) with a third (metadata) argument crashes in the KurrentDB projection engine - the metadata branch reads an out-of-bounds parameter and throws. The two-argument form works; metadata is never captured. gaffer reproduces the crash.
Problem
fromAll().when({ Archived(state, event) { linkStreamTo("archive-" + event.streamId, event.streamId, { reason: "x" }); // 3-arg form crashes return state; }});Fix
fromAll().when({ Archived(state, event) { linkStreamTo("archive-" + event.streamId, event.streamId); // two args; metadata isn't captured return state; }});quirk.log.multiParam
Section titled “quirk.log.multiParam”Severity: Warning · Fixed in: not yet shipped upstream
log() with more than one argument behaves oddly in the KurrentDB engine: primitive arguments are emitted as separate log lines, and objects are joined with a , separator. Pass a single pre-formatted argument to avoid surprises.
Problem
fromAll().when({ Ping(state, event) { log("seen", event.streamId); // multiple args render oddly return state; }});Fix
fromAll().when({ Ping(state, event) { log(`seen ${event.streamId}`); return state; }});quirk.event.bodyCast
Section titled “quirk.event.bodyCast”Severity: Error · Fixed in: KurrentDB 26.2.0
Accessing event.body throws in the KurrentDB engine when the event body is a non-object JSON value - null, or a primitive like a number or string. The upstream EnsureBody casts to an object without a type check. Use event.bodyRaw and parse it yourself. (gaffer’s JS testing library normalizes a data: null event to an absent body, so a null body won’t reproduce the throw there.)
Problem
fromAll().when({ Measured(state, event) { return { latest: event.body }; // throws when the body is a primitive (42, "x", null) }});Fix
fromAll().when({ Measured(state, event) { return { latest: JSON.parse(event.bodyRaw) }; // parse bodyRaw yourself }});quirk.serialize.nonFinite
Section titled “quirk.serialize.nonFinite”Severity: Error · Fixed in: KurrentDB 26.2.0
The KurrentDB engine throws when projection state contains NaN or Infinity (JSON has no representation for them). Guard non-finite numbers in your handler, e.g. store null or 0.
Problem
fromAll().when({ Sampled(state, event) { state.avg = state.total / state.count; // count 0 -> Infinity, throws on persist return state; }});Fix
fromAll().when({ Sampled(state, event) { state.avg = state.count > 0 ? state.total / state.count : 0; return state; }});quirk.serialize.rawString
Section titled “quirk.serialize.rawString”Severity: Error · Fixed in: KurrentDB 26.2.0
When a projection’s state is a bare string that isn’t valid JSON - whether a handler returned it or V1 adopted an unhandled event’s body as state - the KurrentDB engine persists it un-encoded (e.g. hello, not "hello"). On the next reload (restart, re-enable, resume) Load() runs JSON.parse on the stored value and throws, so the projection won’t resume. Wrap string state in an object (e.g. { value: "hello" }), or use KurrentDB 26.2.0+ where the engine JSON-encodes string state. (Bi-state state-array slots are unaffected - they always JSON-encode.)
Problem
fromAll().when({ Set(state, event) { return event.body.name; // bare string state }});Fix
fromAll().when({ Set(state, event) { return { name: event.body.name }; }});quirk.biState.sharedStateResetOnV2
Section titled “quirk.biState.sharedStateResetOnV2”Severity: Error · Fixed in: not yet shipped upstream
Bi-state projections (those declaring $initShared, operating on a [partitionState, sharedState] pair) are not supported under engine_version 2. The shared-state slot is not restored on restart: after a node restart, projection re-enable, or resume, the engine re-runs $initShared instead of reading the persisted shared state, silently producing incorrect results. Use engine_version 1 until KurrentDB implements shared-state restore on V2.
Problem
options({ biState: true });fromAll().foreachStream().when({ $initShared() { return { total: 0 }; }, Deposit([state, shared], event) { shared.total += event.body.amount; return [state, shared]; }});Fix
// Run on engine_version 1; V2 does not restore shared state on restart.quirk.biState.nonArrayReturn
Section titled “quirk.biState.nonArrayReturn”Severity: Error · Fixed in: not yet shipped upstream
Bi-state projections operate on a [partitionState, sharedState] pair. A handler must mutate that pair in place or return it. If it returns anything else (a single slot, a fresh object, a scalar, or null), the KurrentDB engine persists the malformed value, then faults on the next event for that partition while restoring the pair, wedging the partition until its state is reset; gaffer reproduces that wedge. Return the [state, sharedState] pair, or omit the return and mutate it in place.
Problem
options({ biState: true });fromAll().foreachStream().when({ $init() { return { balance: 0 }; }, $initShared() { return { total: 0 }; }, Deposited([state, shared], event) { shared.total += event.body.amount; return { balance: state.balance + event.body.amount }; // returns an object, not the [state, shared] pair }});Fix
options({ biState: true });fromAll().foreachStream().when({ $init() { return { balance: 0 }; }, $initShared() { return { total: 0 }; }, Deposited([state, shared], event) { state.balance += event.body.amount; shared.total += event.body.amount; return [state, shared]; // return the pair (or omit the return and mutate in place) }});quirk.outputState.noEffectOnV2
Section titled “quirk.outputState.noEffectOnV2”Severity: Warning · Fixed in: not yet shipped upstream
outputState() has no effect under engine_version 2. V2 does not emit Result events to a result stream - state is written only to the $projections-{name}[-{partition}]-state stream and must be polled (or that stream subscribed to). Live result-stream parity is planned for a future release; use engine_version 1 until then if you rely on result-stream subscriptions.
Problem
fromAll().when({ Counted(state, event) { return { count: state.count + 1 }; }}).outputState(); // no effect under engine_version 2Fix
// Run on engine_version 1, or read the $projections-{name}-state stream.Issues in your own projection code.
usage.linkStreamTo.deprecated
Section titled “usage.linkStreamTo.deprecated”Severity: Information
linkStreamTo is undocumented in KurrentDB and may be removed in a future version. Prefer linkTo.
Problem
fromAll().when({ Archived(state, event) { linkStreamTo("archive-" + event.streamId, event.streamId); // undocumented, may be removed return state; }});Fix
fromAll().when({ Archived(state, event) { linkTo("archive-" + event.streamId, event); // prefer linkTo return state; }});usage.transforms.notInvoked
Section titled “usage.transforms.notInvoked”Severity: Warning
transformBy()/filterBy() are registered but never invoked under engine_version 2 - the result equals the post-handler state. Use engine_version 1 for V1 transform behaviour.
Problem
fromAll().when({ Counted(state, event) { return { count: state.count + 1 }; }}).transformBy(state => ({ total: state.count })); // not invoked under engine_version 2Fix
// Produce the final shape in the handler, or run on engine_version 1.fromAll().when({ Counted(state, event) { return { total: state.total + 1 }; }});usage.options.duplicate
Section titled “usage.options.duplicate”Severity: Information
options() is called more than once; only the last call takes effect and earlier ones are discarded. Merge them into a single call.
Problem
options({ biState: true });options({ resultStreamName: "results" }); // overwrites the first; biState is lostfromAll().when({ /* ... */ });Fix
options({ biState: true, resultStreamName: "results" });fromAll().when({ /* ... */ });usage.reorderEvents.noEffectOnV2
Section titled “usage.reorderEvents.noEffectOnV2”Severity: Warning
reorderEvents/processingLag have no effect under engine_version 2 - events are processed in arrival order. Use engine_version 1 if you need event reordering.
Problem
options({ reorderEvents: true, processingLag: 100 }); // no effect under engine_version 2fromStreams("a", "b").when({ /* ... */ });Fix
// Run on engine_version 1 if you need event reordering.usage.handler.async
Section titled “usage.handler.async”Severity: Error
async handlers are not supported: the projection engine runs synchronously, so the returned Promise is serialized as the state (which becomes {}) instead of being awaited. Make the handler synchronous.
Problem
fromAll().when({ async Loaded(state, event) { // async isn't supported; state becomes {} return state; }});Fix
fromAll().when({ Loaded(state, event) { return state; }});usage.handler.promise
Section titled “usage.handler.promise”Severity: Error
Returning a Promise from a handler is not supported: the engine runs synchronously, so the Promise is serialized as the state (which becomes {}) instead of being awaited. Return the state synchronously.
Problem
fromAll().when({ Loaded(state, event) { return Promise.resolve(state); // Promise serialized as state -> {} }});Fix
fromAll().when({ Loaded(state, event) { return state; }});