Skip to content

Update Jint to 4.15.3 and adopt the newer embedding surface - #5690

Draft
lahma wants to merge 4 commits into
kurrent-io:masterfrom
lahma:jint-4.15.0
Draft

lahma wants to merge 4 commits into
kurrent-io:masterfrom
lahma:jint-4.15.0

Conversation

@lahma

@lahma lahma commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Updates Jint from 4.0.3 (released 2024-09-23) to 4.15.3, and adopts the parts of the newer embedding surface that fit what this repository already does. Draft, because a few commits are judgement calls the maintainers should make rather than inherit, and each is flagged below.

Qodo's bot review findings are addressed — see the section at the end.


What changed

Three commits, one per area of the codebase, each independently reviewable:

Commit
Update Jint 4.0.3 → 4.15.3 and align the scripting engines with its interop defaults the bump, the behaviour-default audit, EnumConversionMode replacing the one IObjectConverter, and the array-conversion audit test
Rebuild the projection envelope on a shared layout and serialize state with Jint the headline: layout-built envelope with lazy parsed members, JsonSerializer replacing ~215 hand-written lines, and the amortizable execution constraint
Roll back global pollution between JavaScript validations JsValidationEngine for the two process-lifetime validation engines
Run Jint's host-contract verifiers in the JavaScript test suites an AppContext switch per test assembly, each with a positive control

Net: +770 / −461 across 16 files.

The second commit groups three changes because they share the per-event path and were measured together; the amortizable constraint sits with it rather than with the bump because it lives in the projection handler. The fourth is separate because it is test-harness policy spanning two assemblies and belongs to none of the other three.


The headline: the envelope is now a shaped object

EventEnvelope was a custom ObjectInstance subclass. A host subclass carries none of the engine's storage flags, so it reaches no member-read inline cache at all: every event.streamId in a handler was a virtual call into a property dictionary, on an object rebuilt for every event.

It is now a plain JsObject built from a static JsObjectLayout, so every envelope in an engine shares one hidden class and a handler's reads stay monomorphic across events even though the object itself is new each time.

This was not expressible when the branch started. A layout's slots held values, so a member that must parse a JSON document could only be a raw descriptor — which deopts the object to the dictionary representation and forfeits the point. That is exactly why the envelope was a subclass, and the branch first worked around it with TryGetOwnPropertyValue on a still-subclassed envelope. Jint 4.15.1's JsObjectLayout.Builder.AddLazy declares a slot whose factory runs on the first read that observes its value, so body, data, metadata and linkMetadata stay lazy on an object that is hidden-class throughout. The two names for the same document share one memo, so a handler reading both parses once.

Consequences worth reviewing:

  • The parsed members are ordinary properties, so 'body' in event and Object.keys(event) answer for them without parsing anything — only reading a value parses. The key set still matches the old envelope exactly: body/data appear together and only for JSON events, metadata/linkMetadata always appear and carry null when their document is absent. That needs two layout variants; see the review findings below.
  • Own-key order is now fixed by the layout instead of by the order members happened to be materialized in. It reproduces the order the previous implementation ended up with, and when_enumerating_the_event_envelope pins it.
  • Layout slots are configurable/enumerable/writable, where the old descriptors were non-writable and non-configurable. A handler assigning to event.streamId used to silently do nothing and now mutates the envelope — a per-event object discarded when the handler returns. Object.freeze would restore the old attributes exactly, but it drops the object to the dictionary representation and would undo the reason for the change, so this is a deliberate trade rather than an oversight. This is the change most worth a second opinion.

Two things fall out. TryGetOwnPropertyValue is gone — it existed to make a host subclass cheap to read, and there is no longer a host subclass on this path. And event.eventType is no longer round-tripped out of the JavaScript property table to key the CLR handler dictionary; it is read straight off the record, as are isJson and bodyRaw.

Why the version moved twice mid-branch

Adopting an API surface is a good way to find out where it does not fit, and the gaps this branch hit were reported upstream rather than worked around permanently. Seven of them shipped while the branch was open, which is why the pin moved from 4.15.0 to 4.15.3:

What did not fit Shipped as
A lazy member could not live in a shaped object, so the envelope had to be a subclass JsObjectLayout.Builder.AddLazy4.15.1
No way to tell whether a CLR array crosses into script, so the LiveView audit was prose GetInteropConversionDiagnostics4.15.1
PropertyFlag had no name for the combinations a host builds Named combinations + docs — 4.15.1
A lazy slot's entry must be null, but the values span's element type was non-nullable, forcing null! ReadOnlySpan<JsValue?>4.15.2
The documented correctness checker for host contracts required cloning Jint and building it in Debug Verifiers gated on an AppContext switch, live in the shipped Release package — 4.15.3
Restoring a global snapshot correctly meant hand-writing a lock/try/finally recipe each time Engine.Advanced.WithRestoredGlobals4.15.3
The shaping a layout is adopted for could be observed once but not asserted Engine.Advanced.HasSharedShape4.15.3

Two of these close loops this branch opened by name. The null! one landed the same week it was reported and removed four null-forgiving operators from the envelope. The verifier one is larger: this PR earlier had to clone Jint, build it in Debug and hand-copy Jint.dll over the one in a test output directory to reach the checks that guard host contracts — the report said that made the documented checker unreachable for anyone who was not already a Jint contributor. 4.15.3 gates them on AppContext.SetSwitch("Jint.EnableHostContractVerification", true) instead, so the shipped Release package runs them; both JavaScript test assemblies now enable it in a module initializer, each with a positive control proving the switch actually took effect. One further gap remains open by choice rather than oversight: Engine.Advanced.GetPropertyAccessSemantics also shipped, but by then the type it would have protected had stopped being a host type — see the verdicts below.

The branch went through an intermediate shape worth knowing about when reading the diff: on 4.15.0 the envelope stayed a host subclass and used TryGetOwnPropertyValue to make its reads cheap. That was verified against a Debug build of Jint, whose verifier checks the hook's contract on every read — it passed, and the verifier was confirmed live by deliberately breaking the hook and watching the run fail. AddLazy then made the subclass unnecessary, so the hook is gone and none of it survives in the final diff.


The 4.14.0 interop defaults

4.14.0 changed two defaults. Both were audited against our usage and both are inherited deliberately rather than pinned back.

Options.Interop.ArrayConversion: CopyLiveView. A CLR T[] crossing into script is now a live fixed-size view: Array.isArray returns false (while instanceof Array stays true), and push/pop/length writes throw TypeError.

Originally this was answered by reading every call site. It is now asserted (InteropArrayConversionAuditTests): 4.15.1 counts array conversions per engine, and mapping a record then running a filter and a field selector over it — including a selector that walks a JSON array in the payload — converts no CLR array under either mode. A third case is a positive control, so the assertion cannot pass by the counters simply never incrementing.

Scope, stated honestly: the test covers the scripting engines, because JsRecord is the only object graph this repository projects into script and therefore the only place a CLR array could appear. The projections engine hands its handlers nothing but JsValues. And the counters deliberately exclude an array crossing under a non-array declared type (IReadOnlyList<T>), which honours that contract through the ordinary wrapper lane — so this asserts "no array conversion", not "no array-shaped value".

Options.Interop.CacheRecentObjectWrappers: falsetrue. JsRecordEvaluator already creates exactly one wrapper and mutates the target behind it, so the cache is a no-op there. Where it does bite is the JsonNode dictionary lane, where a fresh ObjectWrapper was previously allocated at every level on every access — an improvement, not a hazard: the ring is bounded, Engine.Dispose() releases it, and no filter or selector mutates the record.

Other deltas across the 17 releases

Audited and not applicable: Error.prototype.stack became an accessor (4.9.3 — we never read it); Function.prototype.toString() stopped returning source text (4.11.0 — JsFunctionValidator reads FunctionDeclaration); static CLR members left instance wrappers (4.1.0 — JsRecord has none); writes to read-only CLR members became a strict-mode TypeError (4.15.0 — every projected member has a setter); TimeoutInterval now replaces instead of accumulating (we call it once); saturated constraint sentinels register nothing (we use none).

Audited and applicable — behaviour changes, not bugs:

  • JSON grammar alignment (4.14.0). -09 and 1. are now rejected as in V8, while raw U+2028/U+2029 in strings and escaped control characters in keys are now accepted. Persisted state or event bodies containing those malformed number forms parsed before and now raise SyntaxError.
  • Options.Culture (4.6.0) stopped driving toLocaleString / localeCompare; they route through Intl, and InvariantCulture resolves to the Intl locale "en". No script in our tests uses them.
  • Runtime error message text was rewritten to match V8/Node (4.7.0), stack traces became V8-styled (4.3.0). Our tests assert only on messages scripts throw themselves; anything downstream parsing Jint's own error text will see different strings.

Blast radius outside this repository

Kurrent.Surge.Core carries its own transitive Jint 4.0.3 reference and builds its own engines for JintRecordTransformer and JintSqlReducer. Central package management lifts it to 4.15.3 without recompilation, and because it predates the option it cannot have pinned ArrayConversion. The audit test above cannot reach those engines — it asserts for the ones this repository constructs. Worth a look from whoever owns Surge.


Per-feature verdicts

Adopted

Feature
Constraint.IsAmortizable TimeConstraint.Check() reads a wall clock and nothing else — it neither counts its own invocations nor budgets a quantity that can grow unboundedly between checks. Being in the exact partition cost a virtual Check() per statement and, since 4.13.0, disarmed the interpreter's tight-loop lanes.
Options.Interop.EnumConversion = String Deletes a one-type IObjectConverter whose mere presence disabled the compiled member-read lane for every wrapped member in the engine.
JsonSerializer (string overload) Deletes ~215 lines of hand-written JSON. See compatibility below.
JsObjectLayout + AddLazy The headline, above.
Capture/RestoreGlobalSnapshot For the two shared validation engines that compile user-supplied JavaScript on a process-lifetime engine and reset nothing.
Engine.Advanced.GetInteropConversionDiagnostics Turns the LiveView audit into an assertion.
PropertyFlag named combinations Replaces a local three-bool shim; AllForbidden for log, NonEnumerable for the functions a projection calls.

The ten explicit Constraints.Reset() calls were reviewed and kept: Engine.Call/Execute reset around themselves, but GetSourceDefinition() runs no script and Load/LoadShared call JsonParser.Parse, which checks constraints from inside its own loops without going through ExecuteWithConstraints.

Skipped, with reasons

Feature Verdict
JsonSerializer.Serialize(IBufferWriter<byte>) Wrong direction for us. Every consumer downstream wants a string; PartitionState even re-parses it with Newtonsoft. The old code produced UTF-8 and immediately transcoded back, so the string overload is the fit.
JsonParser.Parse(ReadOnlySpan<byte>) Skipped for projections, follow-up for indexing. In projections the transcode is unavoidable: ResolvedEvent decodes eagerly into a public string field used all over V1, and bodyRaw is script-visible. In KurrentDB.Scripting it is a real opportunity — JsRecord.Value/Properties deserialize evt.Data.Span into a JsonNode tree that then crosses as an ObjectWrapper — but that is a model change with its own script-visible surface.
Engine.Advanced.GetPropertyAccessSemantics Skipped — the type it would have protected no longer exists. The pin guards a host-defined type silently flipping ExoticOrdinary when a Get override moves. The envelope is now an in-box JsObject, for which Jint documents the answer as an internal classification that may be refined in any release. The only remaining host subclass, InterpreterRuntime, is definition-phase only and not reachable from a test project.
TryGetOwnPropertyValue Adopted, then removed. It earned its place while the envelope was a host subclass, and nothing else here is one.
CaptureGlobalSnapshot for projection handlers A handler's engine state is meant to persist — the accumulated _state, the installed globals. Nothing to reset between events.
Typed AddObjectConverter(converter, params Type[]) Superseded: EnumConversionMode removes the converter outright.
Options.AddLazyGlobal Would change definition-phase visibility. emit/linkTo/linkStreamTo/copyTo are installed after Execute(source) precisely so the definition phase cannot see them.
Prepared<Script> / ReferencedGlobals Needs a host-side refactor, not an API adoption. PrepareScript is used nowhere, so the V2 partition fan-out reparses per partition and on every restart. Real win, own change.
ArrayLikeObject, JsObjectShape No host type projects an indexed collection; JsObjectShape describes singleton prototypes, not per-item records.
NullPropagatingReferenceResolver Changes language semantics for projection authors.
Constraint factory overload Not needed: each handler builds its own Options inline with a fresh TimeConstraint.
Options.AddImmutableCrossing (4.15.3) Declined — the promise is false where it would pay. See below.
PropertyDescriptor.CreateLazy (4.15.3) N/A. It installs a lazy descriptor on a host ObjectInstance subclass; our lazy members are layout slots, and there is no host subclass left on this path.

Serializer compatibility

when_serializing_state already asserted that our output was byte-identical to Jint's built-in serializer — for every object, array, value, string and number case it covers, plus the big_state.json production fixture. Those assertions pass unchanged after the swap. That is the compatibility oracle, and this repository wrote it.

Two deliberate deviations were pinned by their own tests:

  • Top-level undefined. The old serializer wrote "null"; Jint reports "no JSON representation". Kept as "null", because every caller treats the result as a JSON document. This now also covers functions, which the old serializer rendered as an object of their non-enumerable members.
  • BigInt. The old serializer wrote the digits as a JSON string. JSON.stringify has no representation for a BigInt and throws, so the projection engines install BigInt.prototype.toJSON to restore the old rendering byte for byte. See the review findings below for why, and for the two consequences.

Everything else the swap changes is a correctness gain: a cycle raises a TypeError instead of running off a fixed 64-entry stack with IndexOutOfRangeException; state nested deeper than 64 levels serializes at all; toJSON is honoured; non-enumerable and symbol-keyed own properties are excluded per spec rather than emitted (or, for symbols, throwing).


Measurements

Projection event processing, end to end. One operation = 100 ProcessEvent calls, each building the event envelope, invoking a handler that reads several members, and serializing the resulting state — the loop a running projection actually executes.

Read the delta for what it is: it spans fifteen Jint releases plus this migration, measured from the merge base to the tip of this branch. It is not the effect of any one commit; it is what an upgrader gets.

BenchmarkDotNet v0.15.2, default job, [MemoryDiagnoser], .NET 10.0.10, AMD Ryzen 9 5950X, quiet machine, both sides run serially from an identical harness compiled against each.

Scenario Before (4.0.3) After (4.15.1)
Handler reads 3 eager envelope members Mean 176.4 µs 103.0 µs −41.6%
Allocated 367.19 KB 178.91 KB −51.3%
…and one member requiring a body parse Mean 275.9 µs 217.7 µs −21.1%
Allocated 533.47 KB 260.89 KB −51.1%

The pin has since moved to 4.15.3; the table is left as measured rather than re-run, since nothing in 4.15.2 or 4.15.3 touches these paths beyond a nullable annotation, a probe-lane fast path, and additions this PR adopts only in tests.

StdDev was under 1.3% of the mean on every row and one outlier was removed per row; no row was multimodal, so no re-pairing was needed. The body-parse row improves less in time because the JSON parse it adds is work neither version avoids — the envelope and serialization around it are what got cheaper. Gen1 collections also disappear: the before side promoted on both rows, the after side reports none.

The allocation figures were independently confirmed by a deterministic probe (GC.GetAllocatedBytesForCurrentThread over the same harness, byte-identical across repeated runs on both sides): 380,713 → 183,913 B/op and 549,898 → 270,778 B/op, agreeing with BenchmarkDotNet at ≈−51%. That is ≈1,968 B and ≈2,791 B saved per event.

Allocation, state serialization in isolation (big_state.json, 210,509 chars, 500 iterations, deterministic probe):

OLD  hand-written + UTF8.GetString :      667,896 B/op
NEW  Jint JsonSerializer           :      403,096 B/op   (-39.6%)

Steady state only; excludes the 1 MB ArrayBufferWriter the old serializer allocated once per handler — i.e. per partition worker under the V2 engine.

The benchmark harness itself is deliberately not part of this PR: it has to compile unchanged against both sides to be a valid A/B, which makes it scaffolding rather than something the repository should carry.


Gates

Full solution build, dotnet build -c Release KurrentDB.slnx: 0 errors, and the only warnings are pre-existing (GitInfo not finding a fork point for a branch named off master, and unused protobuf imports).

Suite Result
KurrentDB.Projections.Management.Tests 523 — 452 passed, 71 skipped, 0 failed
KurrentDB.Projections.JavaScript.Tests 235 — 235 passed
KurrentDB.Projections.V2.Tests 59 — 59 passed
KurrentDB.Kontext.Tests 407 — 407 passed
KurrentDB.SecondaryIndexing.Tests 101 — 101 passed

Both JavaScript test assemblies now run with Jint's host-contract verifiers enabled, so those totals are green with the checks on.

Not run locally: anything requiring Docker or a live cluster.

While the envelope was still a host subclass, the projections suites were additionally run against a Debug build of Jint, whose verifier checks the TryGetOwnPropertyValue contract on every read; they passed, and the verifier was confirmed live by deliberately breaking the hook and watching the run fail. That is no longer needed — the envelope is an in-box JsObject with no host-implemented read contract to verify. What replaced it as the check is when_enumerating_the_event_envelope, which pins each layout variant's observable key set and order.


Why AddImmutableCrossing was declined

4.15.3 added it, and this branch's own friction report is where it came from — the walk record.value.customer.country re-wrapping every node on every access. Having asked for it, the honest answer on inspection is that it does not fit here.

The memo is rooted on a wrapper, and the root of every such walk is JsRecord. Remap mutates that object in place for each event — along with its Schema and Position children — so the promise is simply false at the root and the type cannot be declared. The memo could only be rooted one level in, at the per-event JsonNode documents, which genuinely are built fresh per event and never mutated by us.

That is where it stops being worth it. Those documents are reachable and writable from user-supplied filter scripts, and while Jint evicts a key's memo on a write through the wrapper, a wrong promise on this path would surface as a wrong value written to a DuckDB index column and served to queries — silent and persisted. A stale read in an indexing path is a worse outcome than a missed optimization, and nothing here needs the win badly enough to take that trade.

The redundancy it would paper over is also better removed than memoized: a four-field index makes one JS call per field, each re-walking the record from scratch, so fusing the selectors into a single call eliminates the repeated walks outright. And the deeper fix is the one the envelope rework already applies on the projections side — parsing the payload into native JS values, so there is no wrapper in the path at all. The friction turned out to want that, not a memo.


Review findings addressed

Qodo's bot review raised three. All three are resolved; two were real.

1. EventType falls back to "" (rule violation). Kept, and made explicit. ResolvedEvent.EventType really is null whenever the resolved event carries no event record, and its declaring project has nullable reference types disabled, so the string annotation asserts nothing — dropping the coalesce would not have been safe. The removed CLR getter read the value back out of the property table through AsString(...) ?? "", so "" is the dispatch key an untyped event has always produced. Making it throw instead would be a real behaviour change on a path that previously dispatched, which is worse than the style violation. Both halves of the parity were checked: the CLR dispatch key stays "", and the JS-visible event.eventType is still fed the raw value, so it stays null there exactly as before. A comment at the assignment now states this.

2. Envelope presence semantics changed (real — fixed). The fixed layout made body/data exist on non-JSON events, and a projection distinguishing them with ('body' in event) would have silently changed meaning. The old conditions were reconstructed from the removed code rather than guessed: metadataRaw and linkMetadataRaw were assigned unconditionally and a null string converts to JS null, not undefined, so metadata and linkMetadata were always present carrying null when absent; only EnsureBody additionally required IsJson, and body/data always appeared together. IsJson is therefore the sole condition, so two layout variants reproduce the old key sets exactly — not the eight a general treatment would need. Each variant is still one shared hidden class, so the cache win survives; a projection handling both kinds of event goes polymorphic at its read sites, which is the honest cost of the key sets genuinely differing.

One deviation is kept deliberately because a fixed layout cannot express it: the old envelope materialized a parsed member as a side effect of the first read, so 'body' in event answered false before anything had read event.body and true afterwards. The answer is now the same before and after — it describes the event, not the reading history. when_enumerating_the_event_envelope now pins the key set and order per variant, covering a JSON event, a non-JSON event, and an event with no metadata documents.

3. BigInt serialization now throws (real — fixed). The blast radius is the point: an already-running projection with a BigInt in its state would fault at serialization and halt checkpointing on upgrade. The old output format was verified from the removed serializer (WriteStringValue(value.ToString()) — quoted decimal digits) and is restored exactly, through BigInt.prototype.toJSON, which JSON.stringify consults before deciding a value has no representation. Installed on the projection engines only; the scripting engines never ran that serializer. Not a replacer passed to Serialize, because a replacer is invoked for every node of every state document on the per-event path whereas toJSON costs nothing until a BigInt is present. Two consequences: round-tripped state turns a BigInt into a string, the same gotcha the created comment describes for dates; and a projection's own JSON.stringify(123n) now succeeds where it used to throw — strictly more lenient, and confined to projection engines. The test pins the string output again.


Adjacent findings, not changed here

Noticed while auditing; none is in this PR.

  1. JsFunctionValidator uses a bare new Engine() — no timeout, no DisableStringCompilation — to Evaluate user-supplied text, so (function(){}) + (function(){while(1){}})() hangs the caller. JintEngineFactory.CreateEngine() would fix it, but it is also Strict(), which could reject validators accepted today.
  2. LinkStreamTo has an empty if (parameters.Length == 3) { }, then reads parameters.At(4) inside a second if (parameters.Length == 3). At returns undefined out of range and Undefined.AsObject() throws, so linkStreamTo(a, b, meta) always throws.
  3. LoadCurrentSharedState assigns _state = jsValue in its non-bi-state branch, overwriting the main state with the shared state.
  4. LinkTo uses | instead of || in a TryGetValue chain.
  5. The projections engine sets no LimitMemory, MaxStatements or LimitRecursion. Untrusted projection JavaScript is bounded by wall clock alone.
  6. Partitioned projections build two envelopes per event, so a partitionBy that reads e.body parses the body twice.
  7. UserIndexProcessor.TryExtractFieldValues makes one JS call per field, each re-walking record.value.…: a 4-field index runs 5 JS calls per event.
  8. JsRecord.Remap allocates two closures per record capturing evt and options even when unused, plus a Guid.ToString and a reflective Enum.Parse.
  9. Per-JS-call metrics are tagged with the event type, which is unbounded metric cardinality driven by user data.

@CLAassistant

CLAassistant commented Jul 28, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@lahma lahma changed the title Update Jint to 4.15.0 and adopt the newer embedding surface Update Jint to 4.15.1 and adopt the newer embedding surface Jul 28, 2026
@lahma lahma changed the title Update Jint to 4.15.1 and adopt the newer embedding surface Update Jint to 4.15.2 and adopt the newer embedding surface Jul 29, 2026
@lahma
lahma marked this pull request as ready for review July 29, 2026 08:09
@lahma
lahma requested a review from a team as a code owner July 29, 2026 08:09
Copilot AI review requested due to automatic review settings July 29, 2026 08:09

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@qodo-code-review

qodo-code-review Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

PR Summary by Qodo

Update Jint 4.0.3 → 4.15.2, rebuild event envelope, add validation isolation

✨ Enhancement 🐞 Bug fix 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Bumps Jint dependency from 4.0.3 to 4.15.2 and audits interop-default changes (array conversion,
 enum conversion mode) against actual usage.
• Rebuilds the JS projection EventEnvelope from a shared JsObjectLayout with lazily-parsed
 members, replacing a custom ObjectInstance subclass so member reads stay monomorphic across
 events.
• Replaces the ~215-line hand-written JSON serializer with Jint's own JsonSerializer, preserving
 BigInt-as-string output via a BigInt.prototype.toJSON hook.
• Introduces JsValidationEngine to roll back global pollution between JavaScript compile-time
 validations on long-lived, process-wide engines.
• Marks the projection execution time constraint as amortizable to avoid a per-statement virtual
 Check() call and re-enable interpreter fast paths.
• Adds audit and regression tests covering array-conversion behavior, validation-engine isolation,
 and envelope key/order/laziness semantics.
Diagram

graph TD
  subgraph Projection Handling
    A["ResolvedEvent"] --> B["EventEnvelope (JsObjectLayout)"] --> C["JS Handler Function"] --> D["JsonSerializer"] --> E["Serialized State"]
  end
  subgraph Validation Engines
    F["User JS Source"] --> G["JsValidationEngine"] --> H[("Long-lived Jint Engine")]
    G --> I["Global Snapshot Restore"]
  end
  J["Jint 4.15.2"] -.->|"interop defaults"| B
  J -.->|"embedding surface"| G
Loading
High-Level Assessment

The PR's approach is appropriate: it adopts Jint's own newer embedding primitives (JsObjectLayout with lazy slots, JsonSerializer, global snapshot/restore, interop conversion diagnostics) rather than maintaining hand-rolled equivalents, which is exactly what those primitives were added for. The alternative of keeping the custom ObjectInstance subclass and hand-written serializer was considered and rejected because it forfeits Jint's inline-cache and native serialization fast paths; the alternative of keeping the custom IObjectConverter for enums was rejected because it disables engine-wide interop fast paths. No materially better strategy was left on the table.

Files changed (12) +577 / -461

Enhancement (3) +255 / -435
JintProjectionStateHandler.csRebuild EventEnvelope on JsObjectLayout and adopt Jint's JsonSerializer +194/-417

Rebuild EventEnvelope on JsObjectLayout and adopt Jint's JsonSerializer

• Replaces the custom ObjectInstance-based EventEnvelope with a plain JsObject built from a shared JsObjectLayout with lazily parsed body/metadata/linkMetadata slots for monomorphic member reads; removes the ~215-line hand-written UTF-8 serializer in favor of Jint's JsonSerializer with a BigInt.prototype.toJSON shim; marks the TimeConstraint as amortizable; and switches property registration helpers to PropertyDescriptor/PropertyFlag idioms.

src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs

JintEngineFactory.csReplace custom enum IObjectConverter with EnumConversionMode.String +10/-18

Replace custom enum IObjectConverter with EnumConversionMode.String

• Removes the hand-written IObjectConverter for enum-to-string conversion and instead sets Options.Interop.EnumConversion to avoid the reflection/boxing fallback triggered engine-wide by registering any object converter.

src/KurrentDB.Scripting/JintEngineFactory.cs

JsValidationEngine.csAdd JsValidationEngine to prevent global pollution across validations +51/-0

Add JsValidationEngine to prevent global pollution across validations

• New sealed class wrapping a long-lived Engine, capturing a global snapshot at construction and restoring it after every Validate call (even on exception) under a lock, bounding accidental global leakage between validations.

src/KurrentDB.Scripting/JsValidationEngine.cs

Bug fix (2) +10 / -7
JsFunctionValidator.csUse JsValidationEngine to isolate validation globals +8/-4

Use JsValidationEngine to isolate validation globals

• Replaces manual lock-based Engine usage with JsValidationEngine so globals defined by one function validation do not leak into the next on the shared long-lived engine.

src/KurrentDB.Api.V2/Modules/Indexes/Validators/JsFunctionValidator.cs

Workspace.csAdopt JsValidationEngine for workspace filter-rule validation +2/-3

Adopt JsValidationEngine for workspace filter-rule validation

• Replaces the manually locked static Engine with JsValidationEngine, removing the explicit lock and relying on its global-rollback semantics when compiling filter expressions.

src/KurrentDB.Kontext/Workspaces/ControlPlane/Workspace.cs

Refactor (1) +11 / -5
JsSerializationMeasurer.csAdapt serialization measurer to Jint's JsonSerializer API +11/-5

Adapt serialization measurer to Jint's JsonSerializer API

• Changes Serialize to accept an externally-owned Jint JsonSerializer and return a string instead of raw bytes, mapping undefined results to the string "null" to match prior behavior.

src/KurrentDB.Projections.JavaScript/Metrics/JsSerializationMeasurer.cs

Tests (5) +300 / -13
InteropArrayConversionAuditTests.csAdd tests auditing Jint array-conversion interop behavior +87/-0

Add tests auditing Jint array-conversion interop behavior

• New test file asserting that mapping and filtering JsRecord objects converts no CLR array under either ArrayConversion mode, using Jint's new interop conversion diagnostics counters, plus a positive control test.

src/KurrentDB.Kontext.Tests/Indexing/InteropArrayConversionAuditTests.cs

JsValidationEngineTests.csAdd tests for JsValidationEngine global rollback +47/-0

Add tests for JsValidationEngine global rollback

• New test file verifying globals defined during validation are not visible to subsequent validations, are restored after exceptions, and host-configured globals survive a restore.

src/KurrentDB.Kontext.Tests/Indexing/JsValidationEngineTests.cs

ProjectionSerializationBenchmarks.csSimplify serialization benchmark to production path only +8/-13

Simplify serialization benchmark to production path only

• Removes the A/B benchmark comparing the hand-written serializer against Jint's, since the hand-written one no longer exists, leaving only the SerializeState benchmark.

src/KurrentDB.MicroBenchmarks/ProjectionSerializationBenchmarks.cs

when_serializing_state.csAdd comment documenting BigInt serialization restoration +5/-0

Add comment documenting BigInt serialization restoration

• Adds explanatory comments to the existing BigInt serialization test clarifying why BigInt.prototype.toJSON is used to preserve prior string output behavior.

src/KurrentDB.Projections.Management.Tests/Services/Jint/Serialization/when_serializing_state.cs

when_enumerating_the_event_envelope.csAdd regression tests pinning EventEnvelope key set and laziness +153/-0

Add regression tests pinning EventEnvelope key set and laziness

• New test suite verifying the envelope's own-key set and order matches the previous implementation for JSON, non-JSON, and missing-metadata events, and that lazy members answer existence checks without being read.

src/KurrentDB.Projections.Management.Tests/Services/Jint/when_enumerating_the_event_envelope.cs

Other (1) +1 / -1
Directory.Packages.propsBump Jint package version to 4.15.2 +1/-1

Bump Jint package version to 4.15.2

• Updates the centrally managed Jint package version from 4.0.3 to 4.15.2.

src/Directory.Packages.props

@qodo-code-review

qodo-code-review Bot commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Code Review by Qodo

🐞 Bugs (1) 📘 Rule violations (1) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Missing EnumConversionMode import 🐞 Bug ≡ Correctness ⭐ New
Description
JintEngineFactory.CreateEngine assigns `options.Interop.EnumConversion =
EnumConversionMode.String` but the file no longer imports the namespace that provides
EnumConversionMode, causing a compile error (e.g., `CS0103: The name 'EnumConversionMode' does not
exist in the current context`). This breaks builds for any project referencing
KurrentDB.Scripting.
Code

src/KurrentDB.Scripting/JintEngineFactory.cs[29]

+			options.Interop.EnumConversion = EnumConversionMode.String;
Evidence
JintEngineFactory references EnumConversionMode without importing Jint.Runtime.Interop.
Another file in the repo explicitly imports Jint.Runtime.Interop, supporting that interop types
(like enum conversion controls) live under that namespace.

src/KurrentDB.Scripting/JintEngineFactory.cs[4-30]
src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[10-18]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`src/KurrentDB.Scripting/JintEngineFactory.cs` uses `EnumConversionMode.String` but does not import/qualify `EnumConversionMode`, which is defined in Jint's interop namespace. This introduces a deterministic compile-time failure.

## Issue Context
A prior `using Jint.Runtime.Interop;` was removed in this file, but the code still references `EnumConversionMode`.

## Fix
Choose one:
1. Re-add the import:
  - `using Jint.Runtime.Interop;`
2. Or fully qualify the enum:
  - `options.Interop.EnumConversion = Jint.Runtime.Interop.EnumConversionMode.String;`

## Fix Focus Areas
- src/KurrentDB.Scripting/JintEngineFactory.cs[4-30]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Envelope presence semantics changed ✓ Resolved 🐞 Bug ≡ Correctness
Description
The shaped EventEnvelope now always declares lazy own-properties for parsed members
(body/data/metadata/linkMetadata), so existence checks like `('body' in
event)/hasOwnProperty('metadata')` can be true even before any read and even when values may be
undefined. This is an observable JavaScript compatibility change vs the prior behavior for non-JSON
events, where body could be omitted entirely.
Code

src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[R865-942]

+		private static readonly JsObjectLayout Layout = JsObjectLayout.CreateBuilder()
+			.Add("partition")
+			.Add("created")
+			.Add("bodyRaw")
+			.Add("metadataRaw")
+			.Add("streamId")
+			.Add("eventId")
+			.Add("eventType")
+			.Add("linkMetadataRaw")
+			.Add("isJson")
+			.Add("category")
+			.Add("sequenceNumber")
+			.AddLazy("body", static (_, state) => ((EventEnvelope)state!).Body)
+			.AddLazy("data", static (_, state) => ((EventEnvelope)state!).Body)
+			.AddLazy("metadata", static (_, state) => ((EventEnvelope)state!).Metadata)
+			.AddLazy("linkMetadata", static (_, state) => ((EventEnvelope)state!).LinkMetadata)
+			.Build();

-		public string StreamId {
-			set => SetOwnProperty("streamId", new PropertyDescriptor(value, false, true, false));
-		}
-		public long SequenceNumber {
-			set => SetOwnProperty("sequenceNumber", new PropertyDescriptor(value, false, true, false));
-		}
-
-		public string EventType {
-			get => _parent.AsString(Get("eventType"), false) ?? "";
-			set => SetOwnProperty("eventType", new PropertyDescriptor(value, false, true, false));
-		}
-
-		public JsValue Body {
-			get {
-				if (TryGetValue("body", out var value) && value is ObjectInstance oi)
-					return oi;
-				if (EnsureBody(out JsValue objectInstance))
-					return objectInstance;
-
-				return Undefined;
-			}
-		}
-
-		private bool EnsureBody(out JsValue value) {
-			if (IsJson && TryGetValue("bodyRaw", out var raw) && raw is not JsUndefined) {
-				var body = raw.IsNull() ? raw : _parser.Parse(raw.AsString());
-				var pd = new PropertyDescriptor(body, false, true, false);
-				SetOwnProperty("body", pd);
-				SetOwnProperty("data", pd);
-				value = body;
-				return true;
-			}
-
-			value = Undefined;
-			return false;
-		}
-
-		public bool IsJson {
-			get => Get("isJson").AsBoolean();
-			set => SetOwnProperty("isJson", new PropertyDescriptor(value, false, true, false));
-		}
-
-		public string? BodyRaw {
-			get => _parent.AsString(Get("bodyRaw"), false);
-			set => SetOwnProperty("bodyRaw", new PropertyDescriptor(value, false, true, false));
-		}
-
-		private JsValue Metadata {
-			get {
-				if (TryGetValue("metadata", out var value) && value is ObjectInstance oi)
-					return oi;
-				if (EnsureMetadata(out value))
-					return value;
-
-				return Undefined;
-			}
-		}
-
-		private bool EnsureMetadata(out JsValue value) {
-			if (TryGetValue("metadataRaw", out var raw) && raw is not JsUndefined) {
-				var metadata = raw.IsNull() ? raw : _parser.Parse(raw.AsString());
-				SetOwnProperty("metadata", new PropertyDescriptor(metadata, false, true, false));
-				{
-					value = metadata;
-					return true;
-				}
-			}
-
-			value = Undefined;
-			return false;
-		}
-
-		public string MetadataRaw {
-			set => FastSetProperty("metadataRaw", new PropertyDescriptor(value, false, true, false));
-		}
-
-		private JsValue LinkMetadata {
-			get {
-				if (TryGetValue("linkMetadata", out var value) && value is ObjectInstance oi)
-					return oi;
-				if (EnsureLinkMetadata(out value))
-					return value;
-
-				return Undefined;
-			}
-		}
-
-		private bool EnsureLinkMetadata(out JsValue value) {
-			if (TryGetValue("linkMetadataRaw", out var raw) && raw is not JsUndefined) {
-				var metadata = raw.IsNull() ? raw : _parser.Parse(raw.AsString());
-				SetOwnProperty("linkMetadata", new PropertyDescriptor(metadata, false, true, false));
-				{
-					value = metadata;
-					return true;
-				}
-			}
-
-			value = Undefined;
-			return false;
-		}
+		private readonly JsonParser _parser;
+		private readonly string? _bodyRaw;
+		private readonly string? _metadataRaw;
+		private readonly string? _linkMetadataRaw;

-		public string LinkMetadataRaw {
-			set => SetOwnProperty("linkMetadataRaw", new PropertyDescriptor(value, false, true, false));
-		}
+		private JsValue? _body;
+		private JsValue? _metadata;
+		private JsValue? _linkMetadata;

-		public string Partition {
-			set => SetOwnProperty("partition", new PropertyDescriptor(value, false, true, false));
-		}
+		/// <summary>The value passed to the projection handler.</summary>
+		public JsObject Value { get; }

-		public string Category {
-			set => SetOwnProperty("category", new PropertyDescriptor(value, false, true, false));
-		}
+		/// <summary>
+		/// The event type, read straight off the CLR record. It used to be read back out of the JavaScript
+		/// property table purely to key a CLR dictionary of handlers.
+		/// </summary>
+		public string EventType { get; }

-		public DateTime Created {
-			// avoid new JsDate(_engine, value) because if the user stores it in their state it will be a date
-			// until the state is serialized and back, after which it will be a string, which would be a gotcha
-			set => SetOwnProperty("created", new PropertyDescriptor(value.ToString("o"), false, true, false));
-		}
+		public bool IsJson { get; }

-		public string EventId {
-			set => SetOwnProperty("eventId", new PropertyDescriptor(value, false, true, false));
-		}
+		public string? BodyRaw => _bodyRaw;

-		public EventEnvelope(Engine engine, JsonParser parser, JintProjectionStateHandler parent) : base(engine) {
+		public EventEnvelope(Engine engine, JsonParser parser, string partition, ResolvedEvent @event, string category) {
			_parser = parser;
-			_parent = parent;
-		}
-
-		public override JsValue Get(JsValue property, JsValue receiver) {
-			if (property == "body" || property == "data") {
-				return Body;
-			}
-
-			if (property == "metadata") {
-				return Metadata;
-			}
-
-			if (property == "linkMetadata") {
-				return LinkMetadata;
-			}
-			return base.Get(property, receiver);
-		}
-
-		public override List<JsValue> GetOwnPropertyKeys(Types types = Types.String | Types.Symbol) {
-			var list = base.GetOwnPropertyKeys(types);
-			return list;
-		}
-
-		public override IEnumerable<KeyValuePair<JsValue, PropertyDescriptor>> GetOwnProperties() {
-			if (!HasOwnProperty("body")) {
-				EnsureBody(out _);
-			}
-
-			if (!HasOwnProperty("metadata")) {
-				EnsureMetadata(out _);
-			}
-
-			if (!HasOwnProperty("linkMetadata")) {
-				EnsureLinkMetadata(out _);
-			}
+			_bodyRaw = @event.Data;
+			_metadataRaw = @event.Metadata;
+			_linkMetadataRaw = @event.PositionMetadata;
+			EventType = @event.EventType ?? "";
+			IsJson = @event.IsJson;
+
+			Value = JsObject.Create(
+				engine,
+				Layout,
+				[
+					partition,
+					// avoid new JsDate(engine, value) because if the user stores it in their state it will be a
+					// date until the state is serialized and back, after which it will be a string, which would
+					// be a gotcha
+					@event.Timestamp.ToString("o"),
+					_bodyRaw,
+					_metadataRaw,
+					@event.EventStreamId,
+					@event.EventId.ToString("D"),
+					@event.EventType,
+					_linkMetadataRaw,
+					IsJson,
+					category,
+					@event.EventSequenceNumber,
+					// The four lazy slots are supplied by their factories; Create rejects a non-null entry
+					// for one.
+					null, null, null, null,
+				],
+				this);
+		}
+
+		// A non-JSON event has no body to parse, which the previous implementation expressed by leaving the
+		// property off the object entirely; now the property exists and reads as undefined. A null raw
+		// document parses to null, as it did before.
+		public JsValue Body => _body ??= !IsJson ? JsValue.Undefined : Parse(_bodyRaw);
+
Evidence
The envelope layout unconditionally adds lazy slots for body/data/metadata/linkMetadata, and
the implementation explicitly documents that for non-JSON events the property now exists but reads
as undefined (previously it was left off). The added test also pins that the parsed members answer
existence checks without being read, demonstrating the new observable contract around property
presence.

src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[865-948]
src/KurrentDB.Projections.Management.Tests/Services/Jint/when_enumerating_the_event_envelope.cs[15-82]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`EventEnvelope` is now built from a fixed `JsObjectLayout` that always includes lazy own-properties for `body`, `data`, `metadata`, and `linkMetadata`. This makes property existence checks (`in`, `hasOwnProperty`, `Object.keys`) observe these members even when the event is non-JSON (where the value reads as `undefined`). That is an observable compatibility change from the prior envelope behavior.

### Issue Context
Some projections may use existence checks (e.g., `('body' in event)`) to distinguish JSON vs non-JSON events. With the fixed layout, those checks no longer reflect whether a body is actually present/parsable.

### Fix Focus Areas
- src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[865-942]

### Suggested fix
Implement two layouts:
- a JSON layout that includes lazy slots for `body`/`data` (and optionally the other parsed members)
- a non-JSON layout that omits `body`/`data` entirely, preserving the previous `in`/enumeration behavior for non-JSON events

Add/extend a unit test that constructs an envelope with `isJson: false` and asserts the intended existence semantics (whatever the maintainers decide).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. BigInt serialization now throws ✓ Resolved 🐞 Bug ☼ Reliability
Description
Projection state serialization now uses Jint's JsonSerializer (JSON.stringify), which throws
JavaScriptException for BigInt values. Projections that previously persisted BigInt state (as
a JSON string via the removed custom serializer) will now fault during serialization.
Code

src/KurrentDB.Projections.JavaScript/Metrics/JsSerializationMeasurer.cs[R12-22]

+	// The serializer is engine-bound and this measurer is constructed before the handler builds its
+	// engine, so it is passed in per call, the same way JsFunctionCallMeasurer takes the function it
+	// is timing. A JsonSerializer is reusable across calls -- it clears its own per-call state -- so
+	// the caller holds one for the life of the handler.
+	public string Serialize(JsonSerializer serializer, JsValue value) {
		using var measurer = new Measurer(tracker);
-		return _serializer.Serialize(value).Span;
+
+		// Jint returns undefined for the values that have no JSON representation at all: undefined
+		// itself, and functions. Callers here treat the result as a JSON document, and the serializer
+		// this replaces wrote "null" for those, so keep that mapping rather than returning null.
+		return serializer.Serialize(value) is JsString json ? json.ToString() : "null";
Evidence
The new serializer path calls JsonSerializer.Serialize(...) and treats its result as JSON. The
updated serialization test asserts that serializing a JsBigInt throws with message "Do not know
how to serialize a BigInt", demonstrating the new runtime failure mode.

src/KurrentDB.Projections.JavaScript/Metrics/JsSerializationMeasurer.cs[11-23]
src/KurrentDB.Projections.Management.Tests/Services/Jint/Serialization/when_serializing_state.cs[120-130]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
State serialization now routes through Jint's `JsonSerializer` (JSON.stringify semantics). JSON.stringify rejects BigInt, so any projection state containing a BigInt will throw and can halt processing/checkpointing.

### Issue Context
A regression test was updated to assert this throw, but this is still a breaking change from the previous serializer that encoded BigInt as a JSON string.

### Fix Focus Areas
- src/KurrentDB.Projections.JavaScript/Metrics/JsSerializationMeasurer.cs[11-23]
- src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[56-60]

### Suggested fix options
Pick one:
1) Preserve prior behavior by defining `BigInt.prototype.toJSON = function () { return this.toString(); }` during engine setup (so JSON.stringify produces a string).
2) Keep the new behavior but catch `JavaScriptException` on serialize and surface a clearer host error pointing to unsupported BigInt-in-state (with guidance).

Also consider documenting the change in projection compatibility notes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. EventType falls back to empty 📘 Rule violation ≡ Correctness
Description
EventEnvelope silently substitutes an empty string when @event.EventType is null, which can mask
malformed events and route handler selection incorrectly. Prefer making null explicit (nullable type
+ handling) or fail fast with a throw if EventType is required.
Code

src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[910]

+			EventType = @event.EventType ?? "";
Evidence
PR Compliance ID 3 forbids silently substituting fallback defaults when null indicates an
error/ambiguity. The new code assigns EventType with ?? "", which is a silent fallback that can
hide an unexpected null event type and affect handler dispatch.

CLAUDE.md: Constructor/Method Parameters Must Be Non-Optional When Required (No Silent Fallback Defaults)
src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[905-912]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`EventEnvelope` sets `EventType = @event.EventType ?? ""`, silently masking null values. This conflicts with the compliance requirement to avoid silent fallback defaults for required values.

## Issue Context
If `ResolvedEvent.EventType` can be null, the type should reflect that (`string?`) and callers should handle it explicitly. If it should never be null, the code should fail fast (e.g., `?? throw new InvalidOperationException(...)`) and use the same non-null value when populating the JS `eventType` property.

## Fix Focus Areas
- src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[905-936]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

To customize comments, go to the Qodo configuration screen, or learn more in the docs.

Previous review results

Review updated until commit 4c5a89b

Results up to commit 5a7f77a ⚖️ Balanced


🐞 Bugs (0) 📘 Rule violations (1) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Remediation recommended
1. EventType falls back to empty 📘 Rule violation ≡ Correctness
Description
EventEnvelope silently substitutes an empty string when @event.EventType is null, which can mask
malformed events and route handler selection incorrectly. Prefer making null explicit (nullable type
+ handling) or fail fast with a throw if EventType is required.
Code

src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[910]

+			EventType = @event.EventType ?? "";
Evidence
PR Compliance ID 3 forbids silently substituting fallback defaults when null indicates an
error/ambiguity. The new code assigns EventType with ?? "", which is a silent fallback that can
hide an unexpected null event type and affect handler dispatch.

CLAUDE.md: Constructor/Method Parameters Must Be Non-Optional When Required (No Silent Fallback Defaults)
src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[905-912]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`EventEnvelope` sets `EventType = @event.EventType ?? ""`, silently masking null values. This conflicts with the compliance requirement to avoid silent fallback defaults for required values.

## Issue Context
If `ResolvedEvent.EventType` can be null, the type should reflect that (`string?`) and callers should handle it explicitly. If it should never be null, the code should fail fast (e.g., `?? throw new InvalidOperationException(...)`) and use the same non-null value when populating the JS `eventType` property.

## Fix Focus Areas
- src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[905-936]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. BigInt serialization now throws ✓ Resolved 🐞 Bug ☼ Reliability
Description
Projection state serialization now uses Jint's JsonSerializer (JSON.stringify), which throws
JavaScriptException for BigInt values. Projections that previously persisted BigInt state (as
a JSON string via the removed custom serializer) will now fault during serialization.
Code

src/KurrentDB.Projections.JavaScript/Metrics/JsSerializationMeasurer.cs[R12-22]

+	// The serializer is engine-bound and this measurer is constructed before the handler builds its
+	// engine, so it is passed in per call, the same way JsFunctionCallMeasurer takes the function it
+	// is timing. A JsonSerializer is reusable across calls -- it clears its own per-call state -- so
+	// the caller holds one for the life of the handler.
+	public string Serialize(JsonSerializer serializer, JsValue value) {
		using var measurer = new Measurer(tracker);
-		return _serializer.Serialize(value).Span;
+
+		// Jint returns undefined for the values that have no JSON representation at all: undefined
+		// itself, and functions. Callers here treat the result as a JSON document, and the serializer
+		// this replaces wrote "null" for those, so keep that mapping rather than returning null.
+		return serializer.Serialize(value) is JsString json ? json.ToString() : "null";
Evidence
The new serializer path calls JsonSerializer.Serialize(...) and treats its result as JSON. The
updated serialization test asserts that serializing a JsBigInt throws with message "Do not know
how to serialize a BigInt", demonstrating the new runtime failure mode.

src/KurrentDB.Projections.JavaScript/Metrics/JsSerializationMeasurer.cs[11-23]
src/KurrentDB.Projections.Management.Tests/Services/Jint/Serialization/when_serializing_state.cs[120-130]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
State serialization now routes through Jint's `JsonSerializer` (JSON.stringify semantics). JSON.stringify rejects BigInt, so any projection state containing a BigInt will throw and can halt processing/checkpointing.

### Issue Context
A regression test was updated to assert this throw, but this is still a breaking change from the previous serializer that encoded BigInt as a JSON string.

### Fix Focus Areas
- src/KurrentDB.Projections.JavaScript/Metrics/JsSerializationMeasurer.cs[11-23]
- src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[56-60]

### Suggested fix options
Pick one:
1) Preserve prior behavior by defining `BigInt.prototype.toJSON = function () { return this.toString(); }` during engine setup (so JSON.stringify produces a string).
2) Keep the new behavior but catch `JavaScriptException` on serialize and surface a clearer host error pointing to unsupported BigInt-in-state (with guidance).

Also consider documenting the change in projection compatibility notes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Envelope presence semantics changed ✓ Resolved 🐞 Bug ≡ Correctness
Description
The shaped EventEnvelope now always declares lazy own-properties for parsed members
(body/data/metadata/linkMetadata), so existence checks like `('body' in
event)/hasOwnProperty('metadata')` can be true even before any read and even when values may be
undefined. This is an observable JavaScript compatibility change vs the prior behavior for non-JSON
events, where body could be omitted entirely.
Code

src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[R865-942]

+		private static readonly JsObjectLayout Layout = JsObjectLayout.CreateBuilder()
+			.Add("partition")
+			.Add("created")
+			.Add("bodyRaw")
+			.Add("metadataRaw")
+			.Add("streamId")
+			.Add("eventId")
+			.Add("eventType")
+			.Add("linkMetadataRaw")
+			.Add("isJson")
+			.Add("category")
+			.Add("sequenceNumber")
+			.AddLazy("body", static (_, state) => ((EventEnvelope)state!).Body)
+			.AddLazy("data", static (_, state) => ((EventEnvelope)state!).Body)
+			.AddLazy("metadata", static (_, state) => ((EventEnvelope)state!).Metadata)
+			.AddLazy("linkMetadata", static (_, state) => ((EventEnvelope)state!).LinkMetadata)
+			.Build();

-		public string StreamId {
-			set => SetOwnProperty("streamId", new PropertyDescriptor(value, false, true, false));
-		}
-		public long SequenceNumber {
-			set => SetOwnProperty("sequenceNumber", new PropertyDescriptor(value, false, true, false));
-		}
-
-		public string EventType {
-			get => _parent.AsString(Get("eventType"), false) ?? "";
-			set => SetOwnProperty("eventType", new PropertyDescriptor(value, false, true, false));
-		}
-
-		public JsValue Body {
-			get {
-				if (TryGetValue("body", out var value) && value is ObjectInstance oi)
-					return oi;
-				if (EnsureBody(out JsValue objectInstance))
-					return objectInstance;
-
-				return Undefined;
-			}
-		}
-
-		private bool EnsureBody(out JsValue value) {
-			if (IsJson && TryGetValue("bodyRaw", out var raw) && raw is not JsUndefined) {
-				var body = raw.IsNull() ? raw : _parser.Parse(raw.AsString());
-				var pd = new PropertyDescriptor(body, false, true, false);
-				SetOwnProperty("body", pd);
-				SetOwnProperty("data", pd);
-				value = body;
-				return true;
-			}
-
-			value = Undefined;
-			return false;
-		}
-
-		public bool IsJson {
-			get => Get("isJson").AsBoolean();
-			set => SetOwnProperty("isJson", new PropertyDescriptor(value, false, true, false));
-		}
-
-		public string? BodyRaw {
-			get => _parent.AsString(Get("bodyRaw"), false);
-			set => SetOwnProperty("bodyRaw", new PropertyDescriptor(value, false, true, false));
-		}
-
-		private JsValue Metadata {
-			get {
-				if (TryGetValue("metadata", out var value) && value is ObjectInstance oi)
-					return oi;
-				if (EnsureMetadata(out value))
-					return value;
-
-				return Undefined;
-			}
-		}
-
-		private bool EnsureMetadata(out JsValue value) {
-			if (TryGetValue("metadataRaw", out var raw) && raw is not JsUndefined) {
-				var metadata = raw.IsNull() ? raw : _parser.Parse(raw.AsString());
-				SetOwnProperty("metadata", new PropertyDescriptor(metadata, false, true, false));
-				{
-					value = metadata;
-					return true;
-				}
-			}
-
-			value = Undefined;
-			return false;
-		}
-
-		public string MetadataRaw {
-			set => FastSetProperty("metadataRaw", new PropertyDescriptor(value, false, true, false));
-		}
-
-		private JsValue LinkMetadata {
-			get {
-				if (TryGetValue("linkMetadata", out var value) && value is ObjectInstance oi)
-					return oi;
-				if (EnsureLinkMetadata(out value))
-					return value;
-
-				return Undefined;
-			}
-		}
-
-		private bool EnsureLinkMetadata(out JsValue value) {
-			if (TryGetValue("linkMetadataRaw", out var raw) && raw is not JsUndefined) {
-				var metadata = raw.IsNull() ? raw : _parser.Parse(raw.AsString());
-				SetOwnProperty("linkMetadata", new PropertyDescriptor(metadata, false, true, false));
-				{
-					value = metadata;
-					return true;
-				}
-			}
-
-			value = Undefined;
-			return false;
-		}
+		private readonly JsonParser _parser;
+		private readonly string? _bodyRaw;
+		private readonly string? _metadataRaw;
+		private readonly string? _linkMetadataRaw;

-		public string LinkMetadataRaw {
-			set => SetOwnProperty("linkMetadataRaw", new PropertyDescriptor(value, false, true, false));
-		}
+		private JsValue? _body;
+		private JsValue? _metadata;
+		private JsValue? _linkMetadata;

-		public string Partition {
-			set => SetOwnProperty("partition", new PropertyDescriptor(value, false, true, false));
-		}
+		/// <summary>The value passed to the projection handler.</summary>
+		public JsObject Value { get; }

-		public string Category {
-			set => SetOwnProperty("category", new PropertyDescriptor(value, false, true, false));
-		}
+		/// <summary>
+		/// The event type, read straight off the CLR record. It used to be read back out of the JavaScript
+		/// property table purely to key a CLR dictionary of handlers.
+		/// </summary>
+		public string EventType { get; }

-		public DateTime Created {
-			// avoid new JsDate(_engine, value) because if the user stores it in their state it will be a date
-			// until the state is serialized and back, after which it will be a string, which would be a gotcha
-			set => SetOwnProperty("created", new PropertyDescriptor(value.ToString("o"), false, true, false));
-		}
+		public bool IsJson { get; }

-		public string EventId {
-			set => SetOwnProperty("eventId", new PropertyDescriptor(value, false, true, false));
-		}
+		public string? BodyRaw => _bodyRaw;

-		public EventEnvelope(Engine engine, JsonParser parser, JintProjectionStateHandler parent) : base(engine) {
+		public EventEnvelope(Engine engine, JsonParser parser, string partition, ResolvedEvent @event, string category) {
			_parser = parser;
-			_parent = parent;
-		}
-
-		public override JsValue Get(JsValue property, JsValue receiver) {
-			if (property == "body" || property == "data") {
-				return Body;
-			}
-
-			if (property == "metadata") {
-				return Metadata;
-			}
-
-			if (property == "linkMetadata") {
-				return LinkMetadata;
-			}
-			return base.Get(property, receiver);
-		}
-
-		public override List<JsValue> GetOwnPropertyKeys(Types types = Types.String | Types.Symbol) {
-			var list = base.GetOwnPropertyKeys(types);
-			return list;
-		}
-
-		public override IEnumerable<KeyValuePair<JsValue, PropertyDescriptor>> GetOwnProperties() {
-			if (!HasOwnProperty("body")) {
-				EnsureBody(out _);
-			}
-
-			if (!HasOwnProperty("metadata")) {
-				EnsureMetadata(out _);
-			}
-
-			if (!HasOwnProperty("linkMetadata")) {
-				EnsureLinkMetadata(out _);
-			}
+			_bodyRaw = @event.Data;
+			_metadataRaw = @event.Metadata;
+			_linkMetadataRaw = @event.PositionMetadata;
+			EventType = @event.EventType ?? "";
+			IsJson = @event.IsJson;
+
+			Value = JsObject.Create(
+				engine,
+				Layout,
+				[
+					partition,
+					// avoid new JsDate(engine, value) because if the user stores it in their state it will be a
+					// date until the state is serialized and back, after which it will be a string, which would
+					// be a gotcha
+					@event.Timestamp.ToString("o"),
+					_bodyRaw,
+					_metadataRaw,
+					@event.EventStreamId,
+					@event.EventId.ToString("D"),
+					@event.EventType,
+					_linkMetadataRaw,
+					IsJson,
+					category,
+					@event.EventSequenceNumber,
+					// The four lazy slots are supplied by their factories; Create rejects a non-null entry
+					// for one.
+					null, null, null, null,
+				],
+				this);
+		}
+
+		// A non-JSON event has no body to parse, which the previous implementation expressed by leaving the
+		// property off the object entirely; now the property exists and reads as undefined. A null raw
+		// document parses to null, as it did before.
+		public JsValue Body => _body ??= !IsJson ? JsValue.Undefined : Parse(_bodyRaw);
+
Evidence
The envelope layout unconditionally adds lazy slots for body/data/metadata/linkMetadata, and
the implementation explicitly documents that for non-JSON events the property now exists but reads
as undefined (previously it was left off). The added test also pins that the parsed members answer
existence checks without being read, demonstrating the new observable contract around property
presence.

src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[865-948]
src/KurrentDB.Projections.Management.Tests/Services/Jint/when_enumerating_the_event_envelope.cs[15-82]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`EventEnvelope` is now built from a fixed `JsObjectLayout` that always includes lazy own-properties for `body`, `data`, `metadata`, and `linkMetadata`. This makes property existence checks (`in`, `hasOwnProperty`, `Object.keys`) observe these members even when the event is non-JSON (where the value reads as `undefined`). That is an observable compatibility change from the prior envelope behavior.

### Issue Context
Some projections may use existence checks (e.g., `('body' in event)`) to distinguish JSON vs non-JSON events. With the fixed layout, those checks no longer reflect whether a body is actually present/parsable.

### Fix Focus Areas
- src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[865-942]

### Suggested fix
Implement two layouts:
- a JSON layout that includes lazy slots for `body`/`data` (and optionally the other parsed members)
- a non-JSON layout that omits `body`/`data` entirely, preserving the previous `in`/enumeration behavior for non-JSON events

Add/extend a unit test that constructs an envelope with `isJson: false` and asserts the intended existence semantics (whatever the maintainers decide).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Qodo Logo

_bodyRaw = @event.Data;
_metadataRaw = @event.Metadata;
_linkMetadataRaw = @event.PositionMetadata;
EventType = @event.EventType ?? "";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

1. eventtype falls back to empty 📘 Rule violation ≡ Correctness

EventEnvelope silently substitutes an empty string when @event.EventType is null, which can mask
malformed events and route handler selection incorrectly. Prefer making null explicit (nullable type
+ handling) or fail fast with a throw if EventType is required.
Agent Prompt
## Issue description
`EventEnvelope` sets `EventType = @event.EventType ?? ""`, silently masking null values. This conflicts with the compliance requirement to avoid silent fallback defaults for required values.

## Issue Context
If `ResolvedEvent.EventType` can be null, the type should reflect that (`string?`) and callers should handle it explicitly. If it should never be null, the code should fail fast (e.g., `?? throw new InvalidOperationException(...)`) and use the same non-null value when populating the JS `eventType` property.

## Fix Focus Areas
- src/KurrentDB.Projections.JavaScript/Services/Interpreted/JintProjectionStateHandler.cs[905-936]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@lahma
lahma marked this pull request as draft July 29, 2026 09:03
@lahma

lahma commented Jul 29, 2026

Copy link
Copy Markdown
Contributor Author

All three findings addressed. Two were real; the first is a parity question rather than a new decision.

1. EventType falls back to "". Kept, with the reasoning now written at the assignment.

I checked whether the coalesce could just be dropped. It cannot: ResolvedEvent.EventType is assigned null whenever the resolved event carries no event record, and KurrentDB.Projections.Shared has nullable reference types disabled, so the string annotation asserts nothing about it.

Nor is "" a new default. The CLR getter this replaced read the value back out of the JavaScript property table through AsString(...) ?? "", so "" is the dispatch key an untyped event has always produced. Making it throw instead would change behaviour on a path that previously dispatched — a worse outcome than the rule violation, on the event-processing hot path of a database.

Both halves of the parity were verified, since the CLR property and the JS-visible property are fed separately: the dispatch key stays "", and the layout is still handed the raw value, so event.eventType stays null in script exactly as before.

2. Envelope presence semantics changed. Agreed, and fixed with conditional layout variants.

The old conditions were reconstructed from the removed code rather than assumed, and they are narrower than they look. metadataRaw and linkMetadataRaw were assigned unconditionally, and a null string converts to JS null rather than undefined, so EnsureMetadata and EnsureLinkMetadata always succeeded — metadata and linkMetadata were always present, carrying null when their document was absent, which is distinct from being absent. Only EnsureBody additionally required IsJson, and body/data always appeared together sharing one descriptor.

IsJson is therefore the sole condition, so two layout variants reproduce the old key sets exactly. Each variant is still one shared hidden class, so the inline-cache win survives; a projection handling both JSON and non-JSON events goes polymorphic at its read sites, which is the honest cost of the key sets genuinely differing and still cheaper than the previous host-subclass path, which reached no cache at all.

One deviation is kept deliberately because a fixed layout cannot express it: the old envelope materialized a parsed member as a side effect of the first read of it, so 'body' in event answered false before anything had read event.body and true afterwards. It now answers the same before and after — it describes the event rather than the reading history.

when_enumerating_the_event_envelope now pins the key set and order per variant, covering a JSON event, a non-JSON event, and an event with no metadata documents.

3. BigInt serialization now throws. Agreed, and reverted to the old behaviour. The blast radius is the point — an already-running projection with a BigInt in its state would fault at serialization and halt checkpointing on upgrade, which is not an acceptable way to discover the change.

Option 1 from the suggestion, with the exact old format verified from the removed serializer first (writer.WriteStringValue(value.ToString()), i.e. quoted decimal digits). BigInt.prototype.toJSON is installed on the projection engines only; the scripting and validation engines never ran that serializer and have no behaviour to preserve. Not a replacer passed to Serialize, because a replacer is invoked for every node of every state document on the per-event path, whereas toJSON costs nothing until a BigInt is actually present.

Two consequences, both documented in the PR description: round-tripped state turns a BigInt into a string, the same gotcha the existing created comment describes for dates; and a projection's own JSON.stringify(123n) now succeeds where it previously threw — strictly more lenient, and confined to projection engines. The serialization test pins the string output again.

The branch has also been repackaged from eleven commits into three, since most of the history was version churn while upstream fixes landed mid-review.

@lahma
lahma marked this pull request as ready for review July 29, 2026 09:07
// reflection + boxing, because a converter must be offered every CLR value before it becomes a
// JsValue, so one converter for one enum cost the compiled member-read lane for every property
// and field read on every wrapped object in the engine.
options.Interop.EnumConversion = EnumConversionMode.String;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Missing enumconversionmode import 🐞 Bug ≡ Correctness

JintEngineFactory.CreateEngine assigns `options.Interop.EnumConversion =
EnumConversionMode.String` but the file no longer imports the namespace that provides
EnumConversionMode, causing a compile error (e.g., `CS0103: The name 'EnumConversionMode' does not
exist in the current context`). This breaks builds for any project referencing
KurrentDB.Scripting.
Agent Prompt
## Issue description
`src/KurrentDB.Scripting/JintEngineFactory.cs` uses `EnumConversionMode.String` but does not import/qualify `EnumConversionMode`, which is defined in Jint's interop namespace. This introduces a deterministic compile-time failure.

## Issue Context
A prior `using Jint.Runtime.Interop;` was removed in this file, but the code still references `EnumConversionMode`.

## Fix
Choose one:
1. Re-add the import:
   - `using Jint.Runtime.Interop;`
2. Or fully qualify the enum:
   - `options.Interop.EnumConversion = Jint.Runtime.Interop.EnumConversionMode.String;`

## Fix Focus Areas
- src/KurrentDB.Scripting/JintEngineFactory.cs[4-30]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@qodo-code-review

Copy link
Copy Markdown
Contributor

Code review by qodo was updated up to the latest commit addf0e9

lahma and others added 4 commits July 29, 2026 14:20
…interop defaults

4.0.3 was released 2024-09-23; this crosses seventeen releases. Every Jint API this
repository touches is present and signature-identical at 4.15.3, so the bump
itself is mechanical. What follows are the behaviour deltas that were audited
against our usage rather than assumed.

Two interop defaults changed in 4.14.0, and both are inherited deliberately:

* Options.Interop.ArrayConversion now defaults to LiveView instead of Copy, so a
  CLR T[] crossing into script becomes a live fixed-size view (Array.isArray
  false, push/pop/length writes throw TypeError). No CLR array crosses the
  boundary anywhere in our own usage: the projections engine hands its handlers
  nothing but JsValues, JsRecord -- the only type we project with
  FromObjectWithType -- has no array member at any level, and the one SetValue
  call passes `new object()`.

  That used to be a claim backed by reading every call site. 4.15.1 counts the
  conversions per engine, so InteropArrayConversionAuditTests asserts it instead:
  mapping a record and running a filter and a field selector over it, including a
  selector that walks a JSON array in the payload, converts no CLR array under
  either mode. The third case is a positive control -- without it the other two
  would pass equally well if the counters never incremented, which is the failure
  mode a diagnostic-backed assertion invites.

  Scope, stated honestly: this covers the scripting engines, because JsRecord is
  the only object graph we project and therefore the only place an array could
  appear. The counters also exclude an array crossing under a non-array declared
  type (IReadOnlyList<T>), which honours that contract through the ordinary
  wrapper lane -- so it asserts "no array conversion", not "no array-shaped
  value".

* Options.Interop.CacheRecentObjectWrappers now defaults to true.
  JsRecordEvaluator already creates exactly one wrapper and mutates the target
  behind it, so the cache changes nothing there. It does help the JsonNode
  dictionary lane, which previously allocated a fresh ObjectWrapper at every
  level on every access; the ring is bounded, Engine.Dispose() releases it, and
  no filter or selector mutates the record, so stable wrapper identity is not
  observable to them.

Also audited and not applicable to us: Error.prototype.stack became an accessor
(4.9.3), Function.prototype.toString stopped returning source text (4.11.0 --
JsFunctionValidator reads FunctionDeclaration, not toString), static CLR members
left instance wrappers (4.1.0), writes to read-only CLR members became a
strict-mode TypeError (4.15.0 -- every projected member has a setter), and
TimeoutInterval now replaces instead of accumulating (we call it once).

Two deltas do reach us and are behaviour changes rather than bugs: JSON parsing
was aligned with the JSON grammar in 4.14.0, so `-09` and `1.` are now rejected
as they are in V8 while raw U+2028/U+2029 in strings are now accepted; and
Options.Culture stopped driving toLocaleString/localeCompare in 4.6.0, which now
route through Intl.

The scripting engines additionally drop a hand-written IObjectConverter in favour
of Options.Interop.EnumConversion. The converter existed so that
record.schema.format read as "Json" rather than 1, but a registered converter
must be offered every CLR value before it becomes a JsValue, so its presence
disabled the compiled member-read lane for every wrapped member in the engine --
record.id, record.sequence, record.timestamp and the rest all fell back to
reflection and boxing. EnumConversionMode.String produces the member name,
falling back to the numeric value for a value with no name, which is exactly what
`Enum.GetName(...) ?? e.ToString()` computed. JsSchemaFormat is the only enum in
the model and all its values are named, so the rendering is unchanged.

4.15.3's Options.AddImmutableCrossing was assessed here and declined. It memoizes
reads through a wrapper whose target the host promises not to mutate, which is
exactly the redundancy this path has -- a four-field index re-walks
record.value.customer.country once per selector. But the root of every such walk
is JsRecord, which Remap mutates in place for every event, along with its Schema
and Position children, so the promise is false at the root and the memo could
only be rooted one level in, at the per-event JsonNode documents. Those are
reachable and writable from user-supplied filter scripts, and a wrong promise
here would surface as a wrong value written to a DuckDB index column and served
to queries -- silent and persisted. The redundancy is better removed than
memoized: fusing the field selectors into a single call eliminates the repeated
walks outright, and parsing the payload into native JS values removes the wrapper
from the path altogether.

Blast radius for reviewers: Kurrent.Surge.Core carries its own transitive Jint
4.0.3 reference and builds its own engines for the connector record transformer
and SQL reducer. Central package management lifts it to 4.15.3 without
recompilation, and because it predates the option it cannot have pinned
ArrayConversion. The audit test cannot reach those engines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e with Jint

Three changes to the projection engine that only make sense together, since they
all concern the per-event path: how the envelope is built, how state is
serialized, and how often the execution constraint is checked.

## The envelope

EventEnvelope was a custom ObjectInstance subclass. A host subclass carries none
of the engine's storage flags, so it reaches no member-read inline cache at all:
every event.streamId in a handler was a virtual call into a property dictionary,
on an object rebuilt for every event. It is now a plain JsObject built from a
shared JsObjectLayout, so envelopes share a hidden class and a handler's reads
stay monomorphic across events even though the object itself is new each time.

This needed Jint 4.15.1. A layout's slots held values, so a member that must
parse a JSON document could only be a raw descriptor, which deopts the object to
the dictionary representation and forfeits the point. AddLazy declares a slot
whose factory runs on the first read that observes its value, so body, data,
metadata and linkMetadata stay lazy on an object that is hidden-class throughout.
The two names for one document share a memo, so a handler reading both parses
once.

There are two layout variants because the previous key set was not fixed, and
projections may branch on which members exist. Reconstructing the old conditions
exactly: metadataRaw and linkMetadataRaw were assigned unconditionally, and a
null string converts to JS null rather than undefined, so metadata and
linkMetadata were always present -- carrying null when their document was absent,
which is distinct from being absent. EnsureBody additionally required IsJson, and
body and data always appeared together sharing one descriptor. IsJson is
therefore the only condition, and two layouts cover it exactly. Each variant is
still one shared hidden class; a projection handling both JSON and non-JSON
events goes polymorphic at its read sites, which is the honest cost of the key
sets genuinely differing and still far cheaper than reaching no cache at all.

The shaping is what the whole change is for, and it is invisible from script --
the representations behave identically and differ only in speed -- so
when_shaping_the_event_envelope asserts it through Engine.Advanced.HasSharedShape
rather than leaving it as a one-time observation. JsObject.Create falls back to
the property dictionary quietly and correctly when it cannot shape an object, and
two of the triggers depend on what the engine has already built rather than on
anything visible at the call site, so the assertion is worth having. It needs the
envelope type visible to the test assembly; InternalsVisibleTo to a test project
is the convention already used by KurrentDB.Core, Api.V2 and Connectors.

One deviation remains and cannot be expressed with a fixed layout: the old
envelope materialized a parsed member as a side effect of the first read, so
"body" in event answered false before anything had read event.body and true
afterwards. The answer is now the same before and after. It describes the event
rather than the reading history.

Layout slots are configurable/enumerable/writable where the old descriptors were
non-writable and non-configurable, so a handler assigning to event.streamId used
to silently do nothing and now mutates the envelope -- a per-event object
discarded when the handler returns. Object.freeze would restore the old
attributes but drops the object to the dictionary representation, undoing the
reason for the change.

event.eventType is no longer round-tripped out of the JavaScript property table
to key the CLR handler dictionary; it is read off the record, as are isJson and
bodyRaw. ResolvedEvent.EventType is null when the resolved event carries no event
record, and its declaring project has nullable reference types disabled, so the
string annotation says nothing. The removed CLR getter coalesced to "" through
AsString(...) ?? "", so "" is the dispatch key an untyped event has always
produced; the coalesce is kept to preserve that rather than to decide something
new, and the JS-visible property is still fed the raw value, so it stays null
there exactly as before.

## State serialization

Deletes ~215 lines of hand-written JSON and routes state, emitted event bodies
and log output through Jint.Native.Json.JsonSerializer -- the implementation
behind JSON.stringify, which has been publicly reachable all along.

Compatibility was not assumed: when_serializing_state already asserted that our
output was byte-identical to Jint's built-in serializer for every object, array,
value, string and number case it covers plus the big_state.json production shape,
and those assertions pass unchanged.

Two deviations the old serializer had are preserved deliberately. Top-level
undefined, and now also functions, serialize as "null" rather than as "no JSON
representation", because every caller treats the result as a JSON document. And
a BigInt still serializes as a JSON string of its digits: JSON.stringify has no
representation for one and throws, which for a projection already running with a
BigInt in its state would mean faulting at serialization and halting
checkpointing on upgrade. It is restored through BigInt.prototype.toJSON, which
JSON.stringify consults before deciding a value has no representation, and which
is installed only on the projection engines -- the scripting engines never ran
the string-writing serializer and have nothing to preserve. Deliberately not a
replacer passed to Serialize: a replacer is invoked for every node of every state
document on the per-event path, whereas toJSON costs nothing until a BigInt is
actually present. Two consequences: round-tripped state turns a BigInt into a
string, the same gotcha the "created" comment describes for dates; and a
projection's own JSON.stringify(123n) now succeeds where it used to throw, which
is strictly more lenient and confined to projection engines.

Everything else the swap changes is a correctness gain: a cycle raises a
TypeError instead of running off the end of a fixed 64-entry stack with an
IndexOutOfRangeException, state nested deeper than 64 levels serializes at all,
toJSON is honoured, and non-enumerable and symbol-keyed own properties are
excluded per spec rather than emitted or throwing.

The direction of the data decided which overload to adopt. 4.15.0 added
Serialize(JsValue, IBufferWriter<byte>) for callers that emit UTF-8, but every
consumer downstream -- PartitionState, EmittedDataEvent -- wants a string, and
PartitionState even re-parses it with Newtonsoft. The old code produced UTF-8 and
transcoded it straight back, so the string overload is the fit: it removes an
encode and a decode per event, and the 1 MB ArrayBufferWriter each handler
allocated up front (one per partition worker under the V2 engine) goes with it.

## The execution constraint

Jint splits registered constraints into an "exact" partition checked before every
statement and an "amortized" one checked every N. Before 4.15.0 the split was a
type test against Jint's own sealed constraints, so a user-derived Constraint
could only be exact. TimeConstraint qualifies as amortizable: Check() reads a
wall clock and nothing else -- it does not count its own invocations, and it does
not budget a quantity that can grow without bound between two checks, the two
cases where amortizing would change what is measured. Being exact cost a virtual
Check() with two TimeSpan materializations per statement and, since 4.13.0, also
disarmed the interpreter's tight-loop lanes, so any projection folding over an
array paid full per-statement bookkeeping. Only detection latency changes, and it
stays bounded because Jint re-checks whenever control returns from host code.

The ten explicit Constraints.Reset() calls were reviewed at the same time and
deliberately kept: Engine.Call/Execute do reset around themselves, but
GetSourceDefinition() runs no script at all, and Load/LoadShared call
JsonParser.Parse, which checks constraints from inside its own loops without
going through ExecuteWithConstraints.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two places compile user-supplied JavaScript on a static engine that lives as long
as the process: workspace filter rules, and index function validation. Both take
a lock, because a Jint engine is not thread safe, and neither resets anything
afterwards.

Evaluating an expression can define globals. A filter is validated as (<expr>),
and a comma expression both defines a global and produces the function that
passes validation -- (g = 1, r => true). On these engines that global then
outlives the request, is visible to every later validation, and is never
collected.

Jint 4.15.0 added a global snapshot for exactly this reuse pattern: capture the
configured global surface once, restore it after each use. JsValidationEngine
pairs it with the lock the two sites already had. The restore itself is Jint's
own WithRestoredGlobals, added in 4.15.3 -- the helper is precisely the
try/finally this had hand-written, and the reason it exists is that getting it
wrong is easy and silent: a restore outside the lock, or not in a finally, means
the validation that throws is the one that leaves state behind. The lock stays
ours, because an engine is single-threaded and that helper is a finally, not a
sandbox.

Each site keeps its own engine configuration; only the lifecycle is shared.

It is worth being precise about what this is: Jint documents the snapshot as a
configuration-reuse primitive, not an isolation boundary. It reverses global
bindings, not mutations of built-in prototypes, so it bounds accumulation rather
than making hostile input safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Jint has verifiers for the contracts a host makes when it implements one of the
engine's extension points -- that TryGetOwnPropertyValue agrees with
GetOwnProperty, that a type claiming ordinary access semantics really has them,
that ProbeOwnProperty agrees with the descriptor. They exist because the engine
trusts those hooks on its hot paths and cannot afford to re-verify them there, so
a violation is otherwise silent: a key disappears from every enumeration, or a
read resolves on the prototype for a property that exists.

Until 4.15.3 they were compiled out of the shipped package, so reaching them
meant cloning Jint and building it in Debug -- which is exactly what this branch
had to do earlier, by hand, dropping a locally built Jint.dll over the one in a
test output directory. 4.15.3 gates them on an AppContext switch instead, so the
Release package on NuGet can run them.

A module initializer per test assembly is what makes it stick: the flag behind
the switch is read once at type initialization, so it has to be set before the
first use of any Jint type, and a module initializer runs before any test in the
assembly. Production is unaffected -- with the switch unset the guards fold away
entirely, which is why it is set here and nowhere else.

Each assembly gets a positive control alongside it, a host object that claims
every name as its own while GetOwnProperty reports them all absent. Without one,
both suites would report exactly the same green whether the verifiers were
running or silently inert, and a switch that must be set before first use is easy
to render inert by accident. In Release the verifiers throw rather than writing a
diagnostic, so a violation is a visible failure.

The projections suite is where this currently has something to check: the
definition-phase DSL object is a host ObjectInstance subclass and reads against
it are verified. The scripting suite has no host subclass today, so there it
guards a future one -- which is the case where the positive control earns its
keep most clearly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@lahma lahma changed the title Update Jint to 4.15.2 and adopt the newer embedding surface Update Jint to 4.15.3 and adopt the newer embedding surface Jul 29, 2026
@lahma
lahma marked this pull request as draft July 29, 2026 11:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants