Repository navigation
[core] QuickJS engine reports its log position and consumes returned event pages; document who names a position - #4106
Conversation
…ames one Every write the QuickJS engine makes from its view of the log now carries `eventCount`, a single inline step's terminal write asks for the inline delta (`sinceCursor`), and the page a World hands back on either is queued for the live VM in position order ahead of the next `events.list`, which now reads forward from a cursor instead of the top of the log. Run-terminal writes and the terminal drain send nothing, as nothing replays the log after them. A World whose ids are not slots turns all of this off and gets the engine's previous behavior. Documents the rule for which writes name a position (the writer holds a loaded log it keeps deciding from) as a per-event table in the event-sourcing docs and above `slotSnapshotParams`, with a World-author pointer in building-a-world. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
🦋 Changeset detectedLatest commit: ebb5337 The changes in this PR will be included in the next version bump. This PR includes changesets to release 16 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
📊 Workflow Benchmarkscommit Backend:
Streams
📈 STSO distribution vs main (inline / queue-hop histograms)1020 steps (inline) Cumulative STSO time: main 159219ms → this run 123712ms (Δ -35507ms, -22%) 📈 CRTT drill-down vs main (RTT distributions & profiles)RTT over stream progress (avg per tenth of stream, bars scaled min→max): RTT by chunk size (avg per log size bin, ~160B → ~12KB serialized, bars scaled min→max): Delivery jitter over stream progress (avg positive CDV per tenth of stream, bars scaled min→max): 📜 Previous results (1)7cd9c3eFri, 11 Sep 2026 16:47:45 GMT · run logs
Streams
ℹ️ Metric definitions & methodologyStreams: first-chunk RTT (the stream-open path, before any buffering/backpressure), CRTT percentiles, and worst delivery stall (CDV max). Cells are medians across iterations; per-run values in the artifacts. No 🔴/🟢 marks until targets attach. The collapsed STSO distribution section above buckets every step gap, split inline (same warm process — pure framework overhead) vs queue-hop (fresh process — dispatch, reinit, replay). The collapsed CRTT drill-down: per-variant RTT histograms (fixed log bins, Best/P75/P90/P99 deltas compare against the most recent benchmark run on Metrics — TTFS: time to first step body (in-deployment start() → first step body) · Fan-out TTFS: fan-out time to first step (in-deployment start() → first of the parallel step bodies to complete) · Fan-out TTLS: fan-out time to last step (in-deployment start() → last of the parallel step bodies to complete, i.e. when the Promise.all resolves) · STSO: step-to-step overhead (gap between consecutive step bodies) · WO: workflow overhead (whole-run time outside step bodies, in-deployment anchored) · CRTT: chunk round-trip time (per-chunk write → read latency, one clock domain: deployment → stream backend → same deployment) · CDV: chunk delay variation / delivery jitter (inter-arrival gap minus inter-write gap per seq-adjacent pair; skew-free; the row is each run's MAX positive value, so one stall moves it) Scenarios — step: one trivial no-op step, no stream; no hooks, so the run stays in turbo mode (in-process fast path) · stream: one streaming step; no hooks, so the run stays in turbo mode (in-process fast path) · hook + stream: registers a hook before one step, which exits turbo mode (dispatch path) · 1020 steps: 1020 trivial sequential steps; STSO is measured between consecutive steps in the given step ranges, and WO is the whole-run overhead outside step bodies · Promise.all(100 steps): 100 trivial no-op steps started together in a single Promise.all; Fan-out TTFS is the first of them to complete and Fan-out TTLS the last, both from the in-deployment clientStart, so their gap is the spread the runtime adds across the fan-out · paced control (100/s, 60B): the control: 300 tiny (~60B) deltas metronome-paced at 100/s — zero workload structure, so it reads the transport floor and flush cadence, and disambiguates transport-wide vs workload-specific when a replay row moves · size sweep (100/s, 160B-12KB): same pacing as the control with deltas padded in rotation across seven log-spaced sizes (~160B–12KB) — rotation decouples size from stream position, so it isolates whether chunk size causes latency · replay gateway-gpt-5.4-nano-2000t (1x): raw provider SSE cadence captured at the AI gateway boundary (gpt-5.4-nano, the most popular gateway model; per-token deltas p50 208B = the modal production chunk size), replayed exactly as measured — the typical customer's workload; its CDV is the typical customer's real delivery jitter · replay eve-gpt-5.6-sol-2000t (1x): a captured eve turn (gpt-5.6-sol, the most-used demanding eve model; ~2000 output tokens = production p50 turn length) replayed exactly as measured — eve's envelope protocol re-ships the cumulative message so sizes ramp 142B→13KB; the demanding outlier tenant's reality · replay eve-gpt-5.6-sol-2000t (2x): the same eve capture at 2x — the headroom/stress row; real fast-tier models emit the same chunk sizes at proportionally higher rate, so time compression is a faithful speed model · first chunk (pooled): every run's seq-0 RTT pooled across all stream scenarios — the first chunk precedes any workload differentiation, so pooling samples one shared stream-open path with exact percentiles Replay cadences (semantic sha256) — eve-gpt-5.6-sol-2000t 🔴 marks a percentile over its target (within target is left unmarked). Targets (p75/p90/p99, ms) — TTFS 200/300/600 All timestamps are deployment-side; runs are triggered in-deployment, so the CI runner and api.vercel.com sit outside every measured window. TTFS = Cold starts stay in the numbers (real bursty-workload latency, inflates P75+); Best is the warm floor. |
🧪 E2E Test Results✅ All tests passed
|
| Passed | Failed | Skipped | Total | |
|---|---|---|---|---|
| ✅ ▲ Vercel Production | 3662 | 0 | 685 | 4347 |
| ✅ 💻 Local Development | 3922 | 0 | 586 | 4508 |
| ✅ 📦 Local Production | 3922 | 0 | 586 | 4508 |
| ✅ 🐘 Local Postgres | 3922 | 0 | 586 | 4508 |
| ✅ 🪟 Windows | 320 | 0 | 2 | 322 |
| ✅ 🌐 Cross-language Conformance | 68 | 0 | 74 | 142 |
| ✅ vercel-http-transport | 690 | 0 | 276 | 966 |
| ✅ vercel-multi-region | 27 | 0 | 0 | 27 |
| ✅ vercel-ws-transport | 557 | 0 | 87 | 644 |
| Total | 17090 | 0 | 2882 | 19972 |
Details by Category
✅ ▲ Vercel Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-node | 133 | 0 | 28 |
| ✅ astro-quickjs | 133 | 0 | 28 |
| ✅ example-node | 133 | 0 | 28 |
| ✅ example-quickjs | 133 | 0 | 28 |
| ✅ express-node | 133 | 0 | 28 |
| ✅ express-quickjs | 133 | 0 | 28 |
| ✅ fastify-node | 133 | 0 | 28 |
| ✅ fastify-quickjs | 133 | 0 | 28 |
| ✅ hono-node | 133 | 0 | 28 |
| ✅ hono-quickjs | 133 | 0 | 28 |
| ✅ nest-node | 133 | 0 | 28 |
| ✅ nest-quickjs | 133 | 0 | 28 |
| ✅ nextjs-turbopack-node | 158 | 0 | 3 |
| ✅ nextjs-turbopack-quickjs | 158 | 0 | 3 |
| ✅ nextjs-webpack-node | 158 | 0 | 3 |
| ✅ nextjs-webpack-quickjs | 158 | 0 | 3 |
| ✅ nitro-node | 133 | 0 | 28 |
| ✅ nitro-quickjs | 133 | 0 | 28 |
| ✅ nuxt-node | 133 | 0 | 28 |
| ✅ nuxt-quickjs | 133 | 0 | 28 |
| ✅ python-node | 66 | 0 | 95 |
| ✅ sveltekit-node | 152 | 0 | 9 |
| ✅ sveltekit-quickjs | 152 | 0 | 9 |
| ✅ tanstack-start-node | 133 | 0 | 28 |
| ✅ tanstack-start-quickjs | 133 | 0 | 28 |
| ✅ vite-node | 133 | 0 | 28 |
| ✅ vite-quickjs | 133 | 0 | 28 |
✅ 💻 Local Development
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 134 | 0 | 27 |
| ✅ astro-stable-quickjs | 134 | 0 | 27 |
| ✅ express-stable-node | 134 | 0 | 27 |
| ✅ express-stable-quickjs | 134 | 0 | 27 |
| ✅ fastify-stable-node | 134 | 0 | 27 |
| ✅ fastify-stable-quickjs | 134 | 0 | 27 |
| ✅ hono-stable-node | 134 | 0 | 27 |
| ✅ hono-stable-quickjs | 134 | 0 | 27 |
| ✅ nest-stable-node | 134 | 0 | 27 |
| ✅ nest-stable-quickjs | 134 | 0 | 27 |
| ✅ nextjs-turbopack-canary-node | 141 | 0 | 20 |
| ✅ nextjs-turbopack-canary-quickjs | 141 | 0 | 20 |
| ✅ nextjs-turbopack-stable-node | 160 | 0 | 1 |
| ✅ nextjs-turbopack-stable-quickjs | 160 | 0 | 1 |
| ✅ nextjs-webpack-canary-node | 141 | 0 | 20 |
| ✅ nextjs-webpack-canary-quickjs | 141 | 0 | 20 |
| ✅ nextjs-webpack-stable-node | 160 | 0 | 1 |
| ✅ nextjs-webpack-stable-quickjs | 160 | 0 | 1 |
| ✅ nitro-stable-node | 134 | 0 | 27 |
| ✅ nitro-stable-quickjs | 134 | 0 | 27 |
| ✅ nuxt-stable-node | 134 | 0 | 27 |
| ✅ nuxt-stable-quickjs | 134 | 0 | 27 |
| ✅ sveltekit-stable-node | 153 | 0 | 8 |
| ✅ sveltekit-stable-quickjs | 153 | 0 | 8 |
| ✅ tanstack-start-node | 134 | 0 | 27 |
| ✅ tanstack-start-quickjs | 134 | 0 | 27 |
| ✅ vite-stable-node | 134 | 0 | 27 |
| ✅ vite-stable-quickjs | 134 | 0 | 27 |
✅ 📦 Local Production
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 134 | 0 | 27 |
| ✅ astro-stable-quickjs | 134 | 0 | 27 |
| ✅ express-stable-node | 134 | 0 | 27 |
| ✅ express-stable-quickjs | 134 | 0 | 27 |
| ✅ fastify-stable-node | 134 | 0 | 27 |
| ✅ fastify-stable-quickjs | 134 | 0 | 27 |
| ✅ hono-stable-node | 134 | 0 | 27 |
| ✅ hono-stable-quickjs | 134 | 0 | 27 |
| ✅ nest-stable-node | 134 | 0 | 27 |
| ✅ nest-stable-quickjs | 134 | 0 | 27 |
| ✅ nextjs-turbopack-canary-node | 141 | 0 | 20 |
| ✅ nextjs-turbopack-canary-quickjs | 141 | 0 | 20 |
| ✅ nextjs-turbopack-stable-node | 160 | 0 | 1 |
| ✅ nextjs-turbopack-stable-quickjs | 160 | 0 | 1 |
| ✅ nextjs-webpack-canary-node | 141 | 0 | 20 |
| ✅ nextjs-webpack-canary-quickjs | 141 | 0 | 20 |
| ✅ nextjs-webpack-stable-node | 160 | 0 | 1 |
| ✅ nextjs-webpack-stable-quickjs | 160 | 0 | 1 |
| ✅ nitro-stable-node | 134 | 0 | 27 |
| ✅ nitro-stable-quickjs | 134 | 0 | 27 |
| ✅ nuxt-stable-node | 134 | 0 | 27 |
| ✅ nuxt-stable-quickjs | 134 | 0 | 27 |
| ✅ sveltekit-stable-node | 153 | 0 | 8 |
| ✅ sveltekit-stable-quickjs | 153 | 0 | 8 |
| ✅ tanstack-start-node | 134 | 0 | 27 |
| ✅ tanstack-start-quickjs | 134 | 0 | 27 |
| ✅ vite-stable-node | 134 | 0 | 27 |
| ✅ vite-stable-quickjs | 134 | 0 | 27 |
✅ 🐘 Local Postgres
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ astro-stable-node | 134 | 0 | 27 |
| ✅ astro-stable-quickjs | 134 | 0 | 27 |
| ✅ express-stable-node | 134 | 0 | 27 |
| ✅ express-stable-quickjs | 134 | 0 | 27 |
| ✅ fastify-stable-node | 134 | 0 | 27 |
| ✅ fastify-stable-quickjs | 134 | 0 | 27 |
| ✅ hono-stable-node | 134 | 0 | 27 |
| ✅ hono-stable-quickjs | 134 | 0 | 27 |
| ✅ nest-stable-node | 134 | 0 | 27 |
| ✅ nest-stable-quickjs | 134 | 0 | 27 |
| ✅ nextjs-turbopack-canary-node | 141 | 0 | 20 |
| ✅ nextjs-turbopack-canary-quickjs | 141 | 0 | 20 |
| ✅ nextjs-turbopack-stable-node | 160 | 0 | 1 |
| ✅ nextjs-turbopack-stable-quickjs | 160 | 0 | 1 |
| ✅ nextjs-webpack-canary-node | 141 | 0 | 20 |
| ✅ nextjs-webpack-canary-quickjs | 141 | 0 | 20 |
| ✅ nextjs-webpack-stable-node | 160 | 0 | 1 |
| ✅ nextjs-webpack-stable-quickjs | 160 | 0 | 1 |
| ✅ nitro-stable-node | 134 | 0 | 27 |
| ✅ nitro-stable-quickjs | 134 | 0 | 27 |
| ✅ nuxt-stable-node | 134 | 0 | 27 |
| ✅ nuxt-stable-quickjs | 134 | 0 | 27 |
| ✅ sveltekit-stable-node | 153 | 0 | 8 |
| ✅ sveltekit-stable-quickjs | 153 | 0 | 8 |
| ✅ tanstack-start-node | 134 | 0 | 27 |
| ✅ tanstack-start-quickjs | 134 | 0 | 27 |
| ✅ vite-stable-node | 134 | 0 | 27 |
| ✅ vite-stable-quickjs | 134 | 0 | 27 |
✅ 🪟 Windows
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack-node | 160 | 0 | 1 |
| ✅ nextjs-turbopack-quickjs | 160 | 0 | 1 |
✅ 🌐 Cross-language Conformance
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ python | 68 | 0 | 74 |
✅ vercel-http-transport
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ example | 133 | 0 | 28 |
| ✅ express | 0 | 0 | 161 |
| ✅ hono | 133 | 0 | 28 |
| ✅ nextjs-turbopack | 158 | 0 | 3 |
| ✅ nitro | 133 | 0 | 28 |
| ✅ vite | 133 | 0 | 28 |
✅ vercel-multi-region
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ nextjs-turbopack | 27 | 0 | 0 |
✅ vercel-ws-transport
| App | Passed | Failed | Skipped |
|---|---|---|---|
| ✅ example | 133 | 0 | 28 |
| ✅ express | 133 | 0 | 28 |
| ✅ nextjs-turbopack | 158 | 0 | 3 |
| ✅ vite | 133 | 0 | 28 |
Sim WorldSimulated world deterministic testing for races. Traces 🟠 world-sim scenario book — 1 fail of 41 total
Full trace: |
About these numbersSizes are gzip; parentheses show the change against
|
…off a write A create response may carry the created event with its payload still a ref descriptor and telemetry fields stripped (world-vercel does, for every type whose entity the runtime does not read off the response). Feeding that copy of a step_failed gave the VM a step that failed with no error in it; the e2e serialization-failure tests saw a FatalError where a SerializationError was expected. The created event now only advances the position the next write names, and is delivered by the page that covers it or by the next listing. wait_completed, which carries nothing a VM reads, opts in. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pranaygp
left a comment
There was a problem hiding this comment.
Counter-review with the context from #4096 and vercel/workflow-server#944.
What it does, in one line: the QuickJS engine now sends eventCount on the writes it makes from its loaded view (and sinceCursor on a lone inline step's terminal write), and instead of merging returned pages into an array like the node engine, it queues them and feeds the live VM in position order ahead of the next list.
Verdict: good to merge, with the notes below, none blocking. The design is sound: QuickJSLogView only delivers contiguous runs above what the VM already has, drops truncated reports whole, only advances the cursor past a delta when the queue is dense below its end, and turns itself off on a non-slot id. Executor writes and run-terminal writes stay silent, which matches the rule #4096 and #944 landed on. Not delivering the bare created event (world-vercel returns it with lazy refs and stripped telemetry) is the right call and the e2e failure that motivated it is a good reason to keep that rule.
Inline:
- The inline-delta gate is looser than the node engine's on open waits and overflow siblings. I think it is safe here, but the reasoning is not recorded; mirror the gate or write it down.
- A wording nit on what the queued feed saves.
- A JSDoc table row in
helpers.tslost its*gutter. - The
hook_createdrow does not hold for QuickJS yet (nosinceCursorthere).
One process note: the branch is based before #4096 merged, so CI validated it against the old executor. There is no overlap in the touched lines and the table already describes the post-#4096 state, so a merge is fine as-is.
| // node engine: several steps would each get a delta from the same | ||
| // cursor, and only one could be taken. Not requested when the log has | ||
| // no cursor to name (tracking off, or nothing read yet). | ||
| const inlineDeltaSinceCursor = |
There was a problem hiding this comment.
This gate is looser than the node engine's requestInlineDelta (runtime.ts ~L4230). The node gate also requires err.waitCount === 0, !suspensionResult.waitTimeout, !openHookWaitState.openWait, pendingSteps.length === 1 and ownedRecoverySteps.length === 0, and its comment gives the reason for the wait half: a wait_completed "is a resolution the replay is waiting on rather than an event it can observe one iteration late, so consuming a delta that predates it would settle the sleep from a view that does not contain its completion."
Here the delta is requested with an open wait and with overflow (queued) siblings when MAX_INLINE_STEPS is 1. I traced the open-wait case and I believe it is safe for this engine: the delta is absorbed, the cursor advances past it, the VM still sees the wait pending, and on the next iteration the timer path either writes wait_completed (or hits EntityConflictError if the continuation delivery beat it) and the listing from the advanced cursor picks the completion up. So it is a one-iteration-late observation, not a divergence. But that argument is not written down anywhere, and the node engine chose to fetch instead. Either mirror the node gate (cheap, and keeps the two engines on one rule) or add the reasoning here so the next reader does not have to re-derive it.
There was a problem hiding this comment.
(AI) Mirrored the node gate in ebb5337: the delta is requested only when this step is the only step outstanding (stepOps.length === 1, so no overflow, unserializable, or earlier-invocation sibling) and no wait is pending in the suspension's ops, plus the existing cursor condition. The wait reasoning is written into the comment. I went with mirroring rather than documenting the looser gate, since one rule across both engines is easier to keep true than two arguments.
| // 2. Cheap progress first: feed newly recorded events into the live | ||
| // VM before blocking on step bodies. | ||
| // VM before blocking on step bodies. What the writes above handed back | ||
| // is delivered first; a listing runs only when that does not reach the |
There was a problem hiding this comment.
Nit on the wording: "a listing runs only when that does not reach the next position" overstates it slightly. When takeQueuedEvents returns a page, this branch continues, and the next iteration's feed finds the queue empty and lists anyway (from a cursor a report does not advance, so it re-reads the reported span and dedupes on seenEventIds). So a report changes what is fed first, but the list before step execution still happens one iteration later. That is the same shape the node engine has, so no change needed, just worth saying so the comment does not promise a saved round-trip that only the delta path actually delivers.
There was a problem hiding this comment.
(AI) Reworded in ebb5337. The comment now says a report changes what is fed first, not whether the pre-step listing happens (it re-runs one iteration later from a cursor the report does not advance, deduped on seenEventIds), and that only the delta path saves a listing outright.
| * | `step_completed`, `step_failed` | step executor | `sinceCursor` only, outside turbo, single inline step | this is where the inline execution loop gets its log back; in turbo or from a queued delivery a fresh replay loads it anyway | | ||
| * | `step_failed` | suspension handler, unserializable input | `eventCount` | the handler holds a log; rare, and follows a `step_created` that drew the page | | ||
| * | `attr_set` | `setAttributes()` API (raw) | nothing | out-of-band | | ||
| | `run_completed`, `run_failed`, `run_cancelled` | replay loop, replay budget, `runs.cancel` | `eventCount` where the node loop's seam stamps it (the QuickJS engine sends nothing); never `sinceCursor` (`deltaRequestCursor` excludes terminal types) | terminal: nothing replays the log afterwards, so a World reads no page for them | |
There was a problem hiding this comment.
Formatting: this table row is missing the leading *, so it sits outside the JSDoc gutter (still inside the comment block, so it compiles, but it breaks the table for anything that renders the doc).
| | `attr_set` | `setAttributes()` outside a workflow | Nothing | Out-of-band. | | ||
| | `run_completed`, `run_failed`, `run_cancelled` | Replay, or `cancel()` | Never `sinceCursor` | Terminal. Nothing replays the log afterwards, so a page would be read by no one. | | ||
|
|
||
| Batched writes (`events.createBatch()`) carry no position and get no page. Both runtime engines follow this table: the `node:vm` engine merges a returned page into the log it replays from, and the QuickJS engine queues it for delivery to its live VM, in position order, ahead of its next `events.list()`. |
There was a problem hiding this comment.
"Both runtime engines follow this table" is not quite true for the hook_created row: the node suspension handler sends eventCount and sinceCursor on one hook write per suspension (that delta is how a hook whose payload already arrived resolves in-process), while this PR's QuickJS dispatchPendingOps sends only eventCount on hook_created. Either note the QuickJS exception in the row, or add the sinceCursor ask on the QuickJS side in a follow-up. The same applies to the helpers.ts copy of the table.
There was a problem hiding this comment.
(AI) Added the sinceCursor ask on the QuickJS side in ebb5337 rather than noting an exception. dispatchPendingOps takes a deltaCursor from the loop and passes it as sinceCursor on the hook_created create when exactly one hook needs creating (the node handler's hookDeltaCursor gate); the createEvent seam takes a delta response through absorbDelta so the cursor moves with it. Pinned by a new test in quickjs-slot-report.test.ts (hook_created reaches the VM off the delta, no listing). Both table copies updated to say the ask is gated on exactly one hook, since that is the part that was imprecise for the node engine too.
…d delta, doc fixes - The QuickJS inline-delta gate now matches the node engine's requestInlineDelta: sole outstanding step (no overflow, unserializable, or earlier-invocation sibling) and no pending wait, with the reasoning for the wait half written down. - A lone hook_created asks for the inline delta (sinceCursor) with the same single-hook gate as the node suspension handler, and the createEvent seam takes a delta response through the delta path. The hook_created row now holds for both engines. - Feed comment no longer claims a report saves the pre-step listing; only the delta does, since a report does not advance the cursor. - Restore the JSDoc gutter on the run-terminal table row in helpers.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
No backport to This is feature/parity work on the experimental QuickJS engine: it closes a documented To override, re-run the Backport to stable workflow manually via |

What
Two things that share one rule.
1. QuickJS engine parity on
eventCount/sinceCursor. The engine's header carried aKNOWN GAPnote: no write it made named a log position, so a World never reported skipped events back to it, and every iteration of its inline loop re-listed the run's log from the top. Now:step_created,wait_created,hook_created,hook_disposed,attr_set, the aborthook_received,wait_completed, and thestep_created+step_failedpair for an unserializable input) goes through onecreateEventseam that attacheseventCount.inlineDeltaSinceCursoronexecuteStep) under the node engine'srequestInlineDeltagate translated to this loop: the step is the only step outstanding and no wait is pending. A lonehook_createdasks for the delta too, under the node suspension handler's single-hook gate.QuickJSLogViewand delivered to the live VM in position order, ahead of the nextevents.list. The list now reads forward from a cursor (seeded by a newpreloadedCursorparam the replay loop passes) instead of from the start of the log, and only runs when the queue cannot account for the next position.wait_completed, which carries nothing a VM reads, is the one opt-in. (The first push fed created events and failed the e2e serialization-failure tests on every world-vercel QuickJS lane with aFatalErrorwhere aSerializationErrorwas expected; that is the failure this rule prevents.)The node engine merges a returned page into the array it replays from. The QuickJS engine holds a live VM that consumes events exactly once, so "merge" there means "queue and deliver in order", and a queued event above a position not yet in hand waits for a listing to fill the gap.
QuickJSLogViewowns that, and its cursor only advances past a delta when the queue is dense below the delta's end, so a listing can never skip an event the VM still needs.2. Documentation of who names a position. A new Events returned on a write section in the event-sourcing docs states the rule (a write carries
eventCount/sinceCursorwhen the process writing it holds a loaded log it keeps deciding from) and tabulates every event type by writer, what it sends, and why. The same table sits aboveslotSnapshotParamsinhelpers.tsfor the code reader, andbuilding-a-world.mdxtells World authors they may decline the report read for types whose writer never holds a log.Docs Preview
eventCountparagraphDepends on
The step-executor rows describe the state after #4096 (executor writes send no
eventCount). Until that merges, the node engine's executor still sends one; the table is written for where the code is going.Validation
quickjs-log-view.test.ts(11 tests): position naming, in-order delivery of a write with its skipped span, hold-back above a hole, truncated report dropped, delta cursor advance and its two refusals, tracking turned off on a non-slot id.quickjs-slot-report.test.ts(4 tests, realworld-local, VM mocked): a suspension write names the loaded position and the listing that follows reads forward from the preload's cursor; a concurrent writer's event comes back on the report and is fed ahead of any listing, in position order; a single inline step's terminal write carriessinceCursorand noeventCount, and the step's events reach the VM from the delta with no listing after it; a lonehook_createdcarries both and the hook event reaches the VM from the delta with no listing.quickjs-runtime, preload, partial-preload, hook-dispose-token) pass unchanged. Fullpackages/coresrc/suite green; the only failures aree2e/files that need a deployment.tsc --noEmitclean; biome clean on the changed files apart from the file's pre-existing complexity warnings.🤖 Generated with Claude Code