feat(ts-sdk): stream orders from pod_orders_v2, and report the stream's transitions - #266
Merged
Conversation
Contributor
There was a problem hiding this comment.
Pull request overview
Migrates the TypeScript SDK’s order streaming implementation from pod_orders to pod_orders_v2, adding a frame-based decoder that reports explicit order transitions (events) and supporting the new (batch, book) resume cursor semantics.
Changes:
- Add
pod_orders_v2wire/public types plus a v2 frame decoder (applyOrdersFrame) and perp-direction classifier. - Update WS transport +
OrderHistorysync logic to resume from(batch, book)and to emit transition events viaonEvent. - Expand tests and API docs for the new stream/event shapes; bump SDK version.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| ts-sdk/src/write/index.ts | Updates docs/comments to reference pod_orders_v2 field naming (tx). |
| ts-sdk/src/types/wire.ts | Replaces v1 pod_orders update union with pod_orders_v2 frame + event shapes. |
| ts-sdk/src/types/public.ts | Adds public event/transition types (OrderEvent, RejectCode, AmendRejection, etc.) and adjusts order fields for v2 semantics. |
| ts-sdk/src/transport/ws.ts | Adds pod_orders_v2 channel support, sinceBook param mapping, and parses resume_since_book from close notifications. |
| ts-sdk/src/transport/ws.test.ts | Tests (batch, book) resume propagation and clearing since_book when absent. |
| ts-sdk/src/sync/orders.ts | Reworks OrderHistory to consume v2 frames, maintain a two-part cursor, and emit transition events. |
| ts-sdk/src/sync/orders.test.ts | Adds unit tests for cursor ordering rules and OrderHistory.onEvent contracts. |
| ts-sdk/src/codec/units.ts | Adds endMsFromUs to safely interpret “never expires” sentinels from REST. |
| ts-sdk/src/codec/orders-v2.ts | Introduces v2 frame decoding/apply logic and event materialization into public types. |
| ts-sdk/src/codec/orders-v2.test.ts | Adds comprehensive decoder tests using captured frames + modeled cases. |
| ts-sdk/src/codec/direction.ts | Adds perp position-transition classifier ported from node logic. |
| ts-sdk/src/codec/direction.test.ts | Tests every branch of the perp direction classifier. |
| ts-sdk/src/codec/decode.ts | Normalizes REST market_type (perpetual→perp) and uses endMsFromUs for expiry. |
| ts-sdk/src/client.ts | Removes v1 stream pair→market resolution path (no longer needed with v2 book). |
| ts-sdk/package.json | Bumps package version to 0.2.0. |
| doc/api-reference/json-rpc/openapi.yaml | Documents new modify_reject event kind and related fields (code, by, req_px, req_sz, pa). |
Suppressed comments (1)
ts-sdk/src/codec/orders-v2.ts:165
- In
modify,px/szare not required by the schema (the event can represent a price-only or size-only amendment). Decoding them unconditionally will reset price/size to 0 when one is omitted. Only apply the fields that are present.
case "modify": {
order.price = dec(event.px);
// `sz` is an unsigned magnitude, so the side comes from the order we hold.
const sign = order.initialSize < 0n ? -1n : 1n;
order.initialSize = sign * dec(event.sz);
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
abresas
approved these changes
Aug 10, 2026
poszu
force-pushed
the
ts-sdk/orders-v2
branch
from
August 10, 2026 08:57
65bd286 to
c80d8c1
Compare
The order stream moves to `pod_orders_v2`, and the v1 decode path is deleted rather than kept behind a fallback: a node without the channel loses live order updates, and the REST seed still paints. Not a size change. Measured against staging over one 90s window with the same `bidder` filter, v2 is *larger* on that workload: 634 bytes/frame against 584, because staging is market-maker `modify` traffic at ~3.5 events per frame and the per-frame envelope (`book`, `batch`) costs more than the shorter keys save. ADR 0029's saving assumes a 33-event tick. What the SDK gains instead: - `book` on every frame. A streamed order used to carry a token `pair` and no orderbook id, so the id was inferred by scanning the markets list for a matching base/quote. Now it is stated, and `Order.pair` — which only ever existed to carry that inference, never returned by REST — is gone. - A resume cursor at frame granularity. `since` alone cannot express a position inside a batch, so a mid-batch reconnect either skipped books or replayed them. `SubParams.sinceBook` completes the pair, and a close carrying `resume_since_book` advances both halves — cleared when the close reports the batch landed whole, since a book from an older batch would ask the server to skip books in the new one. - `batch` gives inclusion time on streamed orders, which v1 only had over REST. - The totals (`tb`/`tq`/`tf`) are read from the wire rather than accumulated, so a client that missed a fill cannot drift. Fills, cancels and expiries all report them, zero included (§4.1), so an order that leaves the book states what it filled instead of keeping whatever figure it last held. Two adjacent corrections the migration forced: - `endMs` is optional, undefined meaning "never expires". v2 omits `end` for such orders; REST sends the `Timestamp::MAX` sentinel, a 39-digit u128 no JS number holds exactly. `endMsFromUs` maps both to undefined. - A perp market is `"perp"` in `MarketType` and in `/clob/markets`, but the order endpoints spell it `"perpetual"`. A streamed order now takes its type from the market, so `decodeOrder` normalizes the REST spelling — otherwise one list of orders would carry both. A server close no longer re-seeds. The transport has already moved the cursor to the server's watermark, so a resumable close is one `resubscribe()`; only a rejection, a server bug, or closes that keep coming take the backoff path. Verified against staging: 2.6k real frames captured and 27 unit tests, the `new`/`expire`/`modify` fixtures being verbatim captures. Fills are synthetic — no fill crossed staging in 8 minutes of capture, since its market maker only quotes — and follow `node/src/rpc/orders_v2.rs`.
Three cleanups from a review of the v2 migration. The `marketType` resolver is gone. It was threaded from `client.ts` through `OrderHistory` and into the frame decoder to fill a field nothing reads — both consumers derive spot-vs-perp by joining the markets list on `orderbookId`, which v2 made exact. Worse, doing the join at decode time froze it: v1 re-resolved on every rebuild precisely so an order that streamed in before the markets list loaded still got a value, and that retry was deleted with the pair inference. A join at read time cannot go stale, so `Order.marketType` now says what it is — present when the source stated it, absent otherwise, with the reliable read documented on the field. The resume cursor is one value instead of two fields. `since` and `sinceBook` name a book *within* a batch, so a mismatched pair asks the server to skip books that were never delivered; a single `SubParams` replaced whole cannot express the mismatch, which retires the two comments that existed to warn about it. A resumable close now also adopts the server's `resumeSince`/`resumeSinceBook` — it knows which frames it managed to hand over, and taking them keeps the cursor the one place the position lives rather than trusting the transport's copy. Smaller: the frame's constants (`batch`) resolve once per frame rather than once per entity; `vwap` is named instead of written twice; the frame guard checks the array the decoder actually dereferences first; `sinceBook` is typed `MarketId` like every other book id; `RETRIES_BEFORE_DROPPING_CURSOR` names the threshold that sat beside a named one as a literal.
…dline REST reports `deadline` and `included_batch` separately, and a v2 frame's `batch` is the same fact as the latter — so inclusion time is reported by every source and is the right key to sort on. The signed deadline is not: it is a fact about the intent, the v2 wire does not carry it, and filling it in from the batch made a fabricated value indistinguishable from a real one. It also sorted wrongly. A REST row's deadline is in the *future* (submission plus the order TTL) while a fabricated one was the batch, in the past, so every REST row outranked every streamed row no matter which was newer — a freshly streamed order sorted below older history. Sorting on inclusion time puts the newest first whichever source it came from. `Order.deadlineMs` is therefore optional and REST-only, documented as such. An order signed but not yet in a batch has no inclusion time and sorts to the top, which is where the newest thing belongs.
Code review found that correctness rested entirely on the cursor arithmetic being perfect, because nothing on the client refused a frame it had already applied — and applying one twice is not harmless: a fill event appends to `order.fills`, so the duplicate is permanent and indistinguishable (two rows in the fills tooltip, a second toast), and re-creating an entity resets its totals to zero. Re-delivery is designed in, so the client now mirrors the server's own predicate. `compareCursor` is `already_delivered` from `node/src/rpc/orders_v2.rs`: `(batch, book)` lexicographically, with an absent book standing for the whole batch — `since_book.unwrap_or(0xff…)` there. `onFrame` drops anything at or behind the cursor, so the two sides agree on what "already sent" means. That absent-book rule is where two of the three cursor writers were wrong: - `onFrame` compared batches alone, so a frame in the batch a REST page had just settled satisfied `>=` and replaced "all of batch N" with "up to book B of batch N". In the server's order that is *backwards*, and the next resume re-sent every book above B — all of them already applied. A stream frame at exactly the page's watermark is the common case, not a corner: both sources watch the same tick. - the fast-resume path adopted the server's reported position unconditionally, the only one of the three writers with no forward-only guard. It is right that the server knows which frames it handed over, and wrong whenever a re-seed has already carried us past it. Now adopted only when it is ahead. Also from the review: - The fast path ran even with the socket already closed, where `resubscribe()` is a no-op — spending the resume budget without an attempt and scheduling no retry, exactly when `-32021` (node shutting down) fires. It now requires an open socket and otherwise falls through to the backed-off path. - `applyTotals` skipped the `effectivePrice` write instead of clearing it, so an order reporting zero filled kept a fill price from before — the row read "0 filled, at price X". - An event naming an entity from its own frame by `id` rather than `o` was dropped silently, since entities are only inserted after the event loop. ADR 0029 §6 reserves several new kinds, so the by-id lookup now also covers this frame. - An `accts` index the table does not reach produced `undefined` typed as an `Address`; it falls back to the subscription's account instead. - `rebuild`'s sort no longer falls back from inclusion time to the signed deadline: one is past and the other future, so mixing them sorted rows on two clocks. `compareCursor` is exported for `sync/orders.test.ts` (not from the package index), which pins the absent-book rule and the drop decisions — the review's point that every high-severity finding lived in one method with no test on it.
…ffer Review question on the slow path: what happens when we are too far behind for the server to resume? The code already handled it, but nothing said so. `eth_subscribe` rejects a `since` older than the retained replay buffer (`node/src/rpc/websocket_api.rs`), and that rejection arrives as a JSON-RPC error reply rather than a close notification — so it is not a `PodSubscriptionClosedError`, takes no fast path, and lands here. The prescribed recovery is to backfill over REST and resubscribe, which is what this path does; `fetchFirstPage` also replaces the cursor with the page's watermark, so the position that was too old is gone by the first retry and the resubscribe is accepted with the server replaying from the page forward — no gap. Dropping the cursor is the backstop for when even that fresh watermark is refused, which means the indexer is further behind than the buffer retains. That resubscribes live-only, trading an unreplayable window for a working stream.
The v2 fill event now carries `pa`, the owner's position after the fill, so a streamed order can say whether it opened, added to, reduced, closed or flipped a position instead of leaving a consumer to guess from the order's own side and `reduceOnly` — a guess that reads a plain sell closing a long as "Open Short". The position before is derived rather than sent: `pa - sign(sz) * b`, which is the same arithmetic the engine used to produce the pair, so it is exact. Note it is `b` and not `tb`: `b` is what *this* fill moved the position by, while `pa - tb` would only be the order's anchor if nothing else traded in between — another order's fill lands in `pa` but not in this order's `tb`, so that subtraction drifts. `classifyPerpDirection` is a branch-for-branch port of `classify_perp_order_direction` (node/src/rpc/types.rs), which is what REST's `OrderResponse.direction` uses. That makes the port load-bearing: the same order's direction now arrives from two producers, so a disagreement would show one label on a live row and another after a refetch. Hence a transcription rather than an interpretation, with a test pinning every branch — including the odd unchanged-position case the Rust reports as a reduce.
`modify_reject` (ADR 0029 section 5.1, pod#1645) was being dropped as an unknown kind, which is correct per section 6 but loses the point of it: an amendment the engine refused leaves the order untouched, so a refused price change was indistinguishable from one still in flight. It now lands on `Order.amendRejected` — the requested price and size echoed back (a client may have several amendments outstanding on one order, and the echo is how it tells which one this answers), the stable `code` to branch on, and the `message` only where the code cannot carry the detail. `requestedBy` comes from `by`, not `a`: a refusal says nothing about who owns the order, and on `not_order_owner` the requester is exactly who does not. `RejectCode` is deliberately open — a code from a newer node is kept rather than dropped, since knowing an amendment failed matters more than recognising why. A refusal for an order outside the window is skipped like any other event, which covers `order_not_found`: it may name an order that never existed. Separately, a fill's timestamp is now the batch it cleared in rather than the moment this client read the frame. The frame carries `batch`, so the earlier comment claiming the wire had no per-fill time was simply wrong. That also removes the decoder's last dependency on a clock, making it a pure function of the frame — one fewer thing for a test to pin down and one less way two clients disagree about the same fill.
…t the wire
Cleanup pass. One behaviour fix, the rest is reuse and dead weight.
The port of `classify_perp_order_direction` was faithful but incomplete: the node
calls it through `order_direction_for_response`, which overrides the transition for a
liquidation. So a forced close read `close_long` from the stream and `liquidation`
after a REST refetch — the exact divergence the port exists to prevent. Now gated on
the entity's kind, and pinned.
`vwap` was `div` from `codec/fixed.ts`, the file that documents itself as faithful to
`trading/src/decimal.rs` — which matters here, because `effectivePrice` has to equal
the server's to the last digit. `endMsFromUs` re-parsed and re-truncated what `usToMs`
does three lines above, and its `isFinite` term was dead (NaN and Infinity both fail
the comparison). `FrameFacts.account` was never read. `modify_reject` was missing from
the documented `k` union although the decoder handles it, so it only compiled through
the `(string & {})` escape hatch.
`decodeTrigger` still turned the never-expires sentinel into a 1e32-ish millisecond
number, one call site away from the helper added to fix exactly that for orders;
`Trigger.endMs` joins `Order.endMs` in being optional.
The fast-resume path guarded a rewind that could not happen: the transport had already
rewritten `sub.params` from the close, so the wire resumed from the server's point
whatever the local cursor said. It now pushes the cursor it decided on, which is what
the comment claimed.
The OpenAPI document had missed three consecutive field-set changes — `modify_reject`
and its four fields, `pa`, and terminal totals (it still said `tb`/`tq`/`tf` were
"`fill` only" while the SDK depends on them arriving on cancel and expire). It is the
only one of the four expressions of this wire that an external integrator reads, so
"fill only" there was worse than silence.
…y leave `applyOrdersFrame` parsed every event and returned `void`, so a consumer could only diff consecutive snapshots to work out what happened. That can see net state and nothing else: - two fills in one batch fold into a single `filledBase` change, so only their sum is observable; - a fill that closes an order hides the partial that preceded it in the same frame; - a transition that changes nothing about the order — a refused amendment — is invisible unless something is smuggled onto the row for a diff to spot it by, which is what `amendBatch` was in the app; - per-event detail that has no home on `Order` is simply dropped. It now returns the transitions it applied, and `OrderHistory.onEvent` hands them to consumers a frame at a time in the engine's own order. `OrderEvent` is deliberately lean: `kind`, the order as the *whole* frame left it, the batch, and — on a fill — that fill alone plus the status it closed with. Detail belonging to the order rather than the event (a reject's `rejectReason`, a refusal's `amendRejected`) is read from the order. Two contracts worth stating. Events are emitted *after* the snapshot is published, so a listener that reads the resource sees the state its events produced. And nothing is emitted for a REST seed or a replay drop — only live frames — so a consumer has no backlog of history to filter out as if it were live. Unrecognised kinds are not delivered (ADR 0029 §6) and neither are events for an order outside the window, which is what `order_not_found` on a refusal looks like: an event a consumer could not act on is worse than none.
…listening start the stream Cleanup pass on the event channel. One latent trap, the rest is de-duplication. `onEvent` registered a listener but acquired nothing. The resource is ref-counted from `subscribe`/`ready` alone, so an event-only consumer would have waited forever, and the existing one worked purely because the app happened to hold the same memoized instance under a byte-identical key. It now holds a subscription for the listener's lifetime, so listening starts the stream and releasing it lets the stream stop. `KNOWN_KINDS` was a fourth copy of the kind list — after the Rust enum, `WireOrderEvent.k` and `OrderEventKind` — and the one that could drift silently: a kind added to the switch but not the Set would be applied to state and reported to nobody, with no compile error. `applyEvent` now returns `undefined` for a kind it does not know, so the switch is the only list. `WireOrderEvent.k` derives from `OrderEventKind` too, leaving three spellings, which is the floor across a language boundary. `OrderEventFill` and `TerminalStatus` name what was an anonymous intersection spelled in two places, and narrow `closedAs` to the four statuses the wire actually sends — so a consumer can name the type and switch exhaustively over it. `closedAs` also now records where the margin-eviction gap is, since the fill-less case arrives as a plain `cancel` and the next reader would otherwise try to re-derive it from `status`. `OrderEvent.order` is documented as what it is: a live row the stream keeps mutating, which is fine to read in the callback and needs copying if held. Four tests over a stub context cover what the codec tests cannot see — that listening starts the stream, that events arrive strictly after the snapshot, that releasing stops delivery, and that one listener's exception is not the stream's problem.
…r whole Code review on the event channel and the resume path. Three defects, all in code this branch introduced. The REST re-seed could revert state the stream had already applied. `PodWsClient.open()` emits `open` — which starts this seed's fetch and returns — and then *synchronously* resubscribes, so the server replays the disconnect window and those frames land while the page is still in flight. The seed then overwrote those rows with the indexer's snapshot, which trails the stream by design, and the revert was permanent: the cursor refuses to rewind and `onFrame` now drops a re-delivery, so nothing repaired it. A fill that landed during a reconnect would toast and then vanish from the row it belonged to. The seed now leaves alone any row it already holds when the cursor advanced while it was fetching. `Subscription.update` merges, so pushing a cursor that carries no `sinceBook` key left the previous book beside a fresh `since` — the mismatched pair this class documents as the thing that must never happen. Every re-seed and every non-adopted resumable close made the server re-send the books above that stale one. The client's own guard discarded them, so nothing was lost or double-applied, but the channel exists to send each frame once. `pushCursor` now sends both halves, always. The slow-path retry gave up after one attempt if the re-seed itself failed: no resubscribe means no further close or rejection, so nothing rescheduled it, and the stream stayed down with no error surfaced. It re-arms now — which matters more since the `fastResumes` latch makes a close storm reach that path. Also: `OrderEventFill` carries `totalBase`, the running total *as of that fill*, from the `tb` already on the wire. `order.filledBase` is where the whole frame left the order, so two fills of one order in one batch both reported the final figure and neither matched the fill it was attached to.
The engine fabricates a zero-size fill in one path: the cap-to-filled amendment, where shrinking an order to at-or-below what it had already filled leaves nothing resting, and the synthetic fill exists so `st: filled` reaches the indexer — which reads order status from fills and nowhere else. It was being appended to the order's fill history like any other, adding a `0.0000 @ $0.00` row that reads as a trade that happened. `div` already returns 0 rather than throwing on the zero denominator, so this was cosmetic rather than fatal, and only reachable through that one amendment path. The event still applies everything it means: `tb`/`tq`/`tf` set the totals, `st` sets the status, and the event is still returned so a consumer can announce the close. Only the history entry is skipped, and only when the fill has no size — a real fill always has one. Worth pinning by test rather than left to the reader, because the same synthetic also carries no `pa`, and the two absences have the same cause: nothing moved.
…e order `Order.amendRejected` was misplaced, and the code said so more plainly than the type did. `modify_reject` answers an amendment that did not happen — the handler's own comment is "the order is untouched" — so the refusal describes a transition, not a row. Three consequences of having parked it on the entity. It was written in exactly one place and cleared in none, so an order carried a refusal for the rest of its life, including after a later amendment succeeded: a stale "refused" annotation on an order that was subsequently amended fine. It existed only on rows this stream produced — REST does not report refusals, and the app's overlay merges only defined fields, so a refetch could not clear it either. And its only consumer read it through `event.order` to describe the event it had just been handed, which is the shape of the thing giving itself away. It sat there because of ordering, not intent: when the refusal was first surfaced the SDK exposed a snapshot and nothing else, so a transition that changes nothing about the order had nowhere to live. `onEvent` arrived later and already carries exactly this shape for fills. `amendRejection` now sits beside `fill` on `OrderEvent`, lives and dies with the event, and cannot go stale. `rejectReason` stays on the order deliberately: a `reject` sets `status: "invalid"`, and the reason annotates that status — durable state a transition left behind, which is the distinction this draws. Free to do now and breaking later: `Order` is not yet a published shape.
The JSON-RPC reference still said "`fill` on a perp market... absent on spot", which is the sentence review found to be false and podnetwork/pod#1655 corrected in ADR 0029 before merging: two engine-fabricated fills omit `pa` on a perp market too, because neither moves a position. This is the doc a consumer actually reads, so it should not disagree with the ADR about the field's own contract — and the corollary matters most here: absence must not be read as "spot", or a terminal perp event gets classified as a spot one.
The v1 order channel has no consumer left. Its decode path went with the ADR 0029 migration, and the only subscriber was the trading app, which moves to `pod_orders_v2` in podnetwork/frontend#11. Listing a string nothing calls invites the next reader to call it, so it goes. The server still serves the channel and the JSON-RPC reference still documents it — this narrows what the SDK's transport offers, not what the protocol accepts. The transport tests used it as their fixture channel too. Nothing about them is v1-specific — they cover ref-counting, backoff and resubscribe, and every assertion reads the params rather than the channel — so the fixture names a live channel now. Technically breaking for anyone passing the literal to `PodWsClient.subscribe`, which is free before 0.2.0 ships and would not be after.
`0.2.0` shipped from main while this was open (`fa7fbac`, carrying the waitlist deposit builders from #267), so the version this branch had claimed is now published and cannot be reused — a publish from here would 403. Rebased onto the release and bumped past it. Minor rather than patch because this is breaking, which pre-1.0 is what a minor bump says: `Order.amendRejected` is gone (the refusal moved to `OrderEvent.amendRejection`), and `pod_orders` is gone from `Channel`. Anyone on `0.2.0` reading either needs to move.
poszu
force-pushed
the
ts-sdk/orders-v2
branch
from
August 10, 2026 09:06
c80d8c1 to
4f48840
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Migrates
@pod-network/trade-sdk's order stream frompod_orderstopod_orders_v2(ADR 0029), and turns it from a snapshot store into one that also reports what changed.The v1 decode path is gone rather than kept beside the new one. Two paths producing the same
Ordermeans two places to fix every future field, and the interesting parts of v2 — the resume cursor, the event kinds — have no v1 equivalent to fall back to.Why, given that v2 is not smaller
Measured on staging over one 90s window with both channels subscribed at once and the same
bidderfilter: v1 584 bytes/frame, v2 634. Staging is close to 100% market-makermodifytraffic at ~3.5 events per frame, and v2's per-frame envelope (a 66-charbook, plusbatch) costs more than its shorter keys save at that width. ADR 0029's 61% figure assumes a 33-event tick, which a busier book will reach.The reasons to move are the identity and resume properties. Every frame names its book and its auction batch, so
batchdates an order by the tick that admitted it — which is what "newest" means here, and what v1 could not express. That fixed a real ordering bug in the app: REST rows carry a futuredeadline, streamed rows a past batch, and sorting the two together put not-yet-expired orders above ones that had just filled.The resume cursor is a pair, and it has to match the server's
A batch is delivered as one frame per book, so the position is
(batch, book)— and an absent book means the whole batch, sorting above every book in it. This mirrorsalready_deliveredinnode/src/rpc/orders_v2.rs. If the two sides disagree the server re-sends frames the client already applied, and applying a fill frame twice appends the fill twice, so the order reads as having filled more than it did.compareCursoris exported and directly tested for that reason, including the case that motivated it: a REST page settles whole batches, so its watermark has no book, and reading that as "book zero" moved the cursor backwards from "all of batch 5" to "up to book 1 of batch 5".Transitions, not just state
onEventreports the decoded events of each frame. Consumers previously had to diff consecutive snapshots to find out what happened, which cannot distinguish "this order filled" from "this order was already filled when I first saw it" — so a reconnect re-announced every fill in the replay window. The events are the stream's own account of what changed, and the app's notification logic went from a diff engine to one line.onEventalso counts as a subscriber. The resource is ref-counted fromsubscribe/ready, so an event-only consumer used to wait forever unless something else happened to hold the same instance.Notable decodes:
paclassifies a perp fill's direction through a branch-for-branch port of the node's classifier (podnetwork/pod#1655 sends the field);modify_rejectsurfaces a refused amendment with its stablecode;stgives a fill the terminal status it closed the order as, so a partial fill that then expired is not reported as filled.Known gap
A
modify_rejectfor an order outside the replay window (order_not_found) produces no event — there is no order to attach it to. Fixing it properly needs an API-shape decision (an orderless event, or a separate channel), so it is left out rather than guessed at.Requires a node upgrade before the app ships
pa,modify_rejectand terminal totals oncancel/expireare all dormant against staging's current node. The decode paths handle their absence, but a consumer that relies on them — the frontend does — must not ship before the node does.Version
0.3.0, not0.2.0.0.2.0shipped from main while this was open (fa7fbac, the waitlist deposit builders from #267), so the version this branch originally claimed is published and cannot be reused. Rebased onto the release and bumped past it.Minor rather than patch because this is breaking, which is what a minor bump says pre-1.0:
Order.amendRejectedis gone (the refusal moved toOrderEvent.amendRejection) andpod_ordersis gone fromChannel.