@0xmonaco/react 1.0.51 → 1.0.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,6 +11,20 @@ import { useMonacoSDK } from "../useMonaco";
11
11
  */
12
12
  /** Debounce for the REST re-read after an undescribable event, in milliseconds. */
13
13
  const RESYNC_DEBOUNCE_MS = 1000;
14
+ /** Avoid a reconnect burst issuing one point read per resting order at once. */
15
+ const ORDER_RESOLUTION_CONCURRENCY = 4;
16
+ /** Release a queue slot even when the underlying client request never settles. */
17
+ const ORDER_RESOLUTION_TIMEOUT_MS = 10_000;
18
+ function withTimeout(request, timeoutMs) {
19
+ let timeoutId;
20
+ const timeout = new Promise((_, reject) => {
21
+ timeoutId = setTimeout(() => reject(new Error("Snapshot order resolution timed out")), timeoutMs);
22
+ });
23
+ return Promise.race([request, timeout]).finally(() => {
24
+ if (timeoutId !== undefined)
25
+ clearTimeout(timeoutId);
26
+ });
27
+ }
14
28
  export function useUserOrders(maxOrders = 50) {
15
29
  const { sdk } = useMonacoSDK();
16
30
  const [orders, setOrders] = useState([]);
@@ -18,6 +32,51 @@ export function useUserOrders(maxOrders = 50) {
18
32
  const [error, setError] = useState(null);
19
33
  const [subscribed, setSubscribed] = useState(false);
20
34
  const clearError = useCallback(() => setError(null), []);
35
+ /**
36
+ * Drop unresolved rows from a REST list read that spans a snapshot.
37
+ *
38
+ * Snapshot absence says a held resting row needs a targeted `getOrder`; it
39
+ * does not say what state the order reached. A list read that started before
40
+ * that snapshot cannot resolve the question, so its omitted resting rows are
41
+ * filtered while terminal rows and snapshot-present rows pass through.
42
+ */
43
+ const withoutSupersededRows = useCallback((rows, readEpoch) => {
44
+ if (snapshotEpoch.current === readEpoch)
45
+ return rows;
46
+ const resting = snapshotRestingIds.current;
47
+ return rows.filter((row) => !isRestingStatus(row.status) || resting.has(row.id));
48
+ }, []);
49
+ /** Apply a new list, reducing against the ref rather than through an updater. */
50
+ const applyOrders = useCallback((next) => {
51
+ const applied = typeof next === "function" ? next(ordersRef.current) : next;
52
+ ordersRef.current = applied;
53
+ setOrders(applied);
54
+ }, []);
55
+ /**
56
+ * The latest applied orders, readable synchronously from a WebSocket callback
57
+ * — which closes over the `orders` of the render that installed it.
58
+ *
59
+ * `applyOrders` reduces against this and `setOrders` only mirrors it for
60
+ * rendering, so no decision and no side effect depends on when React chooses
61
+ * to invoke a functional updater. Same arrangement as `useUserBalances`.
62
+ */
63
+ const ordersRef = useRef([]);
64
+ /**
65
+ * Bumped every time a snapshot is applied, with the resting ids that snapshot
66
+ * carried.
67
+ *
68
+ * A REST read that STARTED before a snapshot landed can still be in flight
69
+ * when it does, and its rows are older than the snapshot for the resting set.
70
+ * Applying them unfiltered can undo a targeted resolution: `mergeOrders`
71
+ * treats a row absent from the current list as REST-only and re-inserts it,
72
+ * even though the spanning read is older than the snapshot that prompted the
73
+ * single-order lookup.
74
+ *
75
+ * `readGeneration` does not cover this. It tracks the SDK/effect lifetime, and
76
+ * a reconnect resubscribes without tearing the effect down.
77
+ */
78
+ const snapshotEpoch = useRef(0);
79
+ const snapshotRestingIds = useRef(new Set());
21
80
  const resyncTimer = useRef(null);
22
81
  /**
23
82
  * Bumped by effect cleanup. Every REST read — the initial load and the resync
@@ -59,6 +118,7 @@ export function useUserOrders(maxOrders = 50) {
59
118
  if (!sdk?.trading)
60
119
  return;
61
120
  const generation = readGeneration.current;
121
+ const readEpoch = snapshotEpoch.current;
62
122
  // A read belonging to a previous client must not write ANY of this state:
63
123
  // not the rows, not the error, and not the loading flag. Clearing
64
124
  // `loading` from a stale request would mark the hook loaded while the new
@@ -70,7 +130,8 @@ export function useUserOrders(maxOrders = 50) {
70
130
  try {
71
131
  const rows = await readOrders();
72
132
  if (rows && current()) {
73
- setOrders((prev) => (mode === "replace" ? rows : mergeOrders(prev, rows, maxOrders)));
133
+ const fresh = withoutSupersededRows(rows, readEpoch);
134
+ applyOrders((prev) => (mode === "replace" ? fresh : mergeOrders(prev, fresh, maxOrders)));
74
135
  }
75
136
  }
76
137
  catch (err) {
@@ -81,7 +142,7 @@ export function useUserOrders(maxOrders = 50) {
81
142
  if (current())
82
143
  setLoading(false);
83
144
  }
84
- }, [sdk?.trading, readOrders, maxOrders]);
145
+ }, [sdk?.trading, readOrders, maxOrders, applyOrders, withoutSupersededRows]);
85
146
  const fetchOrders = useCallback(() => load("replace"), [load]);
86
147
  /**
87
148
  * Manual refresh. Reconciles rather than replaces: unlike the initial load
@@ -108,8 +169,8 @@ export function useUserOrders(maxOrders = 50) {
108
169
  * spinner), and it must not replace the list wholesale (an event landing
109
170
  * while the request is in flight would be overwritten by an older snapshot —
110
171
  * the very race the initial load avoids by subscribing only after it
111
- * completes). It merges instead, last-writer-wins by `updatedAt`, the same
112
- * reconciliation the SDK documents for `onResync`.
172
+ * completes). It merges instead, ranking on `version` when either side has
173
+ * one and falling back to `updatedAt` only when neither does.
113
174
  *
114
175
  * Debounced and coalesced: a deploy that adds an order type produces a burst
115
176
  * of declines, and one REST read repairs the whole burst. A failure is
@@ -121,19 +182,22 @@ export function useUserOrders(maxOrders = 50) {
121
182
  const generation = readGeneration.current;
122
183
  resyncTimer.current = setTimeout(() => {
123
184
  resyncTimer.current = null;
185
+ // Captured where the READ starts, not where it was scheduled: a snapshot
186
+ // landing during the debounce is already reflected by the time this runs.
187
+ const readEpoch = snapshotEpoch.current;
124
188
  void readOrders()
125
189
  .then((rows) => {
126
190
  // Clearing the timer cannot cancel a read that already started, so
127
191
  // the generation is what invalidates it.
128
192
  if (rows && generation === readGeneration.current) {
129
- setOrders((prev) => mergeOrders(prev, rows, maxOrders));
193
+ applyOrders((prev) => mergeOrders(prev, withoutSupersededRows(rows, readEpoch), maxOrders));
130
194
  }
131
195
  })
132
196
  .catch(() => {
133
197
  // A repair that fails leaves the list exactly as it was.
134
198
  });
135
199
  }, RESYNC_DEBOUNCE_MS);
136
- }, [readOrders, maxOrders]);
200
+ }, [readOrders, maxOrders, applyOrders, withoutSupersededRows]);
137
201
  useEffect(() => {
138
202
  if (!sdk?.ws || !sdk?.trading) {
139
203
  setSubscribed(false);
@@ -145,7 +209,7 @@ export function useUserOrders(maxOrders = 50) {
145
209
  setLoading(false);
146
210
  return;
147
211
  }
148
- setOrders([]);
212
+ applyOrders([]);
149
213
  setError(null);
150
214
  setLoading(true);
151
215
  const limit = Number.isFinite(maxOrders) ? maxOrders : 50;
@@ -158,6 +222,80 @@ export function useUserOrders(maxOrders = 50) {
158
222
  // would never be removed.
159
223
  let cancelled = false;
160
224
  const generation = readGeneration.current;
225
+ // Generation-scoped queue for snapshot omissions. The set covers QUEUED
226
+ // and ACTIVE work, so another reconnect snapshot reuses the same request
227
+ // rather than launching a duplicate for that order.
228
+ const queuedOrActiveOrderReads = new Set();
229
+ const orderReadQueue = [];
230
+ let activeOrderReads = 0;
231
+ const enqueueOrderReads = (orderIds) => {
232
+ for (const orderId of orderIds) {
233
+ if (queuedOrActiveOrderReads.has(orderId))
234
+ continue;
235
+ const started = ordersRef.current.find((row) => row.id === orderId);
236
+ if (!started)
237
+ continue;
238
+ queuedOrActiveOrderReads.add(orderId);
239
+ orderReadQueue.push({ orderId, started });
240
+ }
241
+ drainOrderReads();
242
+ };
243
+ const runOrderRead = async (task) => {
244
+ let retry = false;
245
+ try {
246
+ const { order: resolved } = await withTimeout(sdk.trading.getOrder(task.orderId), ORDER_RESOLUTION_TIMEOUT_MS);
247
+ if (cancelled || generation !== readGeneration.current)
248
+ return;
249
+ const current = ordersRef.current;
250
+ const held = current.find((row) => row.id === task.orderId);
251
+ if (held && held.version === undefined && resolved.version === undefined) {
252
+ if (held === task.started) {
253
+ // Neither side can be ranked, and the two REST surfaces use
254
+ // different clocks. With no intervening local change, the point
255
+ // read is the answer this omission explicitly requested, so take
256
+ // it without consulting `updatedAt`.
257
+ const withoutHeld = current.filter((row) => row.id !== task.orderId);
258
+ applyOrders(mergeOrders(withoutHeld, [keepingPostOnly(resolved, held)], limit));
259
+ }
260
+ else {
261
+ // An unversioned event or later snapshot changed this row while the
262
+ // request was in flight. Preserve that change; if the latest
263
+ // snapshot still omits a resting row, retry from the new baseline
264
+ // once this in-flight slot is released.
265
+ retry = isRestingStatus(held.status) && !snapshotRestingIds.current.has(task.orderId);
266
+ }
267
+ }
268
+ else {
269
+ // Versioned results are safe to merge even when state changed while
270
+ // the request was in flight: the shared comparator picks the newer
271
+ // state and gives a point-read final state the equal-version tie.
272
+ applyOrders(mergeOrders(current, [resolved], limit));
273
+ }
274
+ }
275
+ catch {
276
+ // Absence or a failed point read is not proof of terminal state. Keep
277
+ // the local row; a later snapshot can enqueue another attempt.
278
+ }
279
+ finally {
280
+ activeOrderReads -= 1;
281
+ queuedOrActiveOrderReads.delete(task.orderId);
282
+ if (!cancelled && generation === readGeneration.current && retry) {
283
+ enqueueOrderReads([task.orderId]);
284
+ }
285
+ else {
286
+ drainOrderReads();
287
+ }
288
+ }
289
+ };
290
+ function drainOrderReads() {
291
+ while (!cancelled && generation === readGeneration.current && activeOrderReads < ORDER_RESOLUTION_CONCURRENCY) {
292
+ const task = orderReadQueue.shift();
293
+ if (!task)
294
+ return;
295
+ activeOrderReads += 1;
296
+ void runOrderRead(task);
297
+ }
298
+ }
161
299
  fetchOrders()
162
300
  .then(() => {
163
301
  if (cancelled || generation !== readGeneration.current)
@@ -172,7 +310,7 @@ export function useUserOrders(maxOrders = 50) {
172
310
  const placed = event.eventType === "OrderPlaced" ? orderFromEvent(event) : null;
173
311
  if (event.eventType === "OrderPlaced" && !placed)
174
312
  scheduleResync();
175
- setOrders((prev) => {
313
+ applyOrders((prev) => {
176
314
  const orderId = event.orderId;
177
315
  // Check if this order already exists
178
316
  const existingIndex = prev.findIndex((o) => o.id === orderId);
@@ -191,6 +329,43 @@ export function useUserOrders(maxOrders = 50) {
191
329
  }
192
330
  return prev;
193
331
  });
332
+ },
333
+ // The subscribe-time snapshot repairs rows it carries without a
334
+ // REST round trip. It matters most after a reconnect: the
335
+ // resubscribe brings a fresh one, while a targeted read resolves
336
+ // each formerly-resting order it omits.
337
+ (items) => {
338
+ // Read BEFORE the updater: whether a row was declined depends only
339
+ // on the rows themselves, and an updater must stay free of side
340
+ // effects because React may invoke it more than once. A row this
341
+ // version cannot describe would otherwise be missing indefinitely
342
+ // — the same repair a declined OrderPlaced gets.
343
+ // Both decisions are made against the ref BEFORE anything is
344
+ // applied, so neither depends on when React invokes an updater.
345
+ const applied = applyOrderSnapshot(ordersRef.current, items, limit);
346
+ // Recorded BEFORE the rows are applied, so a REST read that
347
+ // resolves after this point can tell that it spans a snapshot and
348
+ // filter resting rows whose absence needs targeted resolution.
349
+ snapshotRestingIds.current = new Set(snapshotOrderRows(items).rows.map((row) => row.id));
350
+ snapshotEpoch.current += 1;
351
+ // A row this version cannot describe would otherwise be missing
352
+ // indefinitely — the same repair a declined OrderPlaced gets.
353
+ if (applied.hasUndescribableRow)
354
+ scheduleResync();
355
+ // A fill this connection never saw moves the fee aggregates the
356
+ // snapshot cannot carry, and REST is the only source for them.
357
+ if (applied.hasOfflineFill)
358
+ scheduleResync();
359
+ applyOrders(applied.orders);
360
+ // Absence from the persisted resting snapshot is ambiguous: the
361
+ // order may have gone terminal during the outage, or persistence
362
+ // may lag a newer row retained from the previous connection.
363
+ // Keep the row visible while a cache-first point read resolves
364
+ // it. `mergeOrders` ranks the answer against any event or newer
365
+ // snapshot that lands while these requests are in flight.
366
+ if (applied.missingRestingOrderIds.length > 0) {
367
+ enqueueOrderReads(applied.missingRestingOrderIds);
368
+ }
194
369
  });
195
370
  setSubscribed(true);
196
371
  }
@@ -211,11 +386,13 @@ export function useUserOrders(maxOrders = 50) {
211
386
  clearTimeout(resyncTimer.current);
212
387
  resyncTimer.current = null;
213
388
  }
389
+ orderReadQueue.length = 0;
390
+ queuedOrActiveOrderReads.clear();
214
391
  // Invalidates any read already in flight; see `readGeneration`.
215
392
  readGeneration.current += 1;
216
393
  setSubscribed(false);
217
394
  };
218
- }, [sdk?.ws, sdk?.trading, maxOrders, fetchOrders, scheduleResync]);
395
+ }, [sdk?.ws, sdk?.trading, maxOrders, fetchOrders, scheduleResync, applyOrders]);
219
396
  return { orders, loading, subscribed, error, clearError, refresh };
220
397
  }
221
398
  /**
@@ -266,6 +443,76 @@ function asKnown(known, value) {
266
443
  function asOrderStatus(value) {
267
444
  return asKnown(ORDER_STATUSES, value);
268
445
  }
446
+ /**
447
+ * `live`, with every REST-only field taken from the fetched row.
448
+ *
449
+ * "REST-only" means no WebSocket frame carries it — neither a snapshot row nor
450
+ * an order event — so the live row's copy is whatever an older read left
451
+ * behind, and a snapshot-derived row has none at all. Each is assigned even
452
+ * when the response omits it: REST is the authority on these, so "not set
453
+ * there" is an answer rather than a gap.
454
+ *
455
+ * `postOnly` is deliberately NOT among them. The `OrderPlaced` acknowledgement
456
+ * carries it and is the authority on it, and REST omitting the key is the
457
+ * ordinary shape of a non-post-only order — so taking it from the response
458
+ * would clear a flag learned from the socket.
459
+ *
460
+ * Written out rather than looped over a key list so it type-checks without a
461
+ * cast: indexing `Order` by a union of keys loses the per-field value type.
462
+ */
463
+ function withRestOnlyFields(live, fetched) {
464
+ return {
465
+ ...live,
466
+ expirationDate: fetched.expirationDate,
467
+ applicationTakerFee: fetched.applicationTakerFee,
468
+ monacoTakerFee: fetched.monacoTakerFee,
469
+ monacoMakerRebate: fetched.monacoMakerRebate,
470
+ totalTakerFees: fetched.totalTakerFees,
471
+ takerTotalPayment: fetched.takerTotalPayment,
472
+ makerTotalReceipt: fetched.makerTotalReceipt,
473
+ positionSide: fetched.positionSide,
474
+ terminalReason: fetched.terminalReason,
475
+ };
476
+ }
477
+ /**
478
+ * Whether an incoming row (a REST response or a snapshot) supersedes the one
479
+ * held locally.
480
+ *
481
+ * Ranked on `version` — the sequencer step every endpoint reports identically
482
+ * for the same order state (0XM-2440). `updatedAt` cannot do this job: order
483
+ * detail carries the matching-engine event clock and order lists the database
484
+ * commit clock, so a stale response can hold the LATER timestamp, and the
485
+ * column can move without the order changing at all.
486
+ *
487
+ * `>=`, not `>`. Equal means the same sequencer step, not identical state — one
488
+ * step emits an `OrderPlaced` and then its fills, and a batch shares one step
489
+ * across every item — and the incoming row is that step's FINAL state, so on a
490
+ * tie it is the authority over an intermediate event.
491
+ *
492
+ * Absent is unknown, never zero. When exactly one side has a version, that is
493
+ * the only rankable side: incoming-known wins and live-known stays. Falling
494
+ * back to timestamps in either asymmetric case would reintroduce the two-clock
495
+ * bug this counter exists to remove. Only when BOTH sides lack a version does
496
+ * the caller use its pre-0XM-2440 fallback.
497
+ */
498
+ function supersedes(incoming, live) {
499
+ if (incoming.version === undefined)
500
+ return live.version === undefined ? "unranked" : false;
501
+ if (live.version === undefined)
502
+ return true;
503
+ return incoming.version >= live.version;
504
+ }
505
+ /**
506
+ * `incoming`, keeping a `postOnly` the incoming row cannot report.
507
+ *
508
+ * REST omitting the key is the ordinary shape of a non-post-only order, and a
509
+ * snapshot row carries no such field at all, so neither can distinguish "not
510
+ * post-only" from "unknown". Only a `true` learned from the `OrderPlaced`
511
+ * acknowledgement is carried forward, and only over an absence.
512
+ */
513
+ function keepingPostOnly(incoming, live) {
514
+ return live.postOnly === true && incoming.postOnly === undefined ? { ...incoming, postOnly: true } : incoming;
515
+ }
269
516
  /** Milliseconds since epoch for an order's `updatedAt`, or `NaN` if unparseable. */
270
517
  function updatedAtMs(order) {
271
518
  return Date.parse(order.updatedAt ?? "");
@@ -279,14 +526,242 @@ function createdAtMs(order) {
279
526
  return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed;
280
527
  }
281
528
  /**
282
- * Reconcile a REST read against live state, last-writer-wins by `updatedAt`.
529
+ * Map one `orders` snapshot row onto the REST `Order` model, or `null` when
530
+ * this SDK version cannot describe it.
531
+ *
532
+ * Same decline contract as {@link orderFromEvent}, for the same reason: the
533
+ * wire values are handed through unvalidated, and relabelling a future order
534
+ * type or side renders an order as something it is not. `status` joins the
535
+ * declining set here, unlike on an `OrderPlaced` — that event IS a submitted
536
+ * order whatever string it carries, whereas a snapshot row's status is the
537
+ * order's actual resting state and has no honest substitute.
538
+ *
539
+ * The row's optionals arrive as explicit `null` (Rust `Option`s serialized
540
+ * without `skip_serializing_if`), so they are normalized to absent: the REST
541
+ * model declares them `?:`, and leaking `null` past it would have consumers
542
+ * branching on a value the model says cannot occur.
543
+ */
544
+ export function orderFromSnapshotItem(item) {
545
+ const orderType = asKnown(ORDER_TYPES, item.orderType);
546
+ const side = asKnown(ORDER_SIDES, item.side);
547
+ const tradingMode = asKnown(TRADING_MODES, item.tradingMode);
548
+ const status = asOrderStatus(item.status);
549
+ if (!orderType || !side || !tradingMode || !status)
550
+ return null;
551
+ return {
552
+ id: item.orderId,
553
+ tradingPairId: item.tradingPairId,
554
+ orderType,
555
+ side,
556
+ status,
557
+ price: item.price ?? undefined,
558
+ quantity: item.quantity,
559
+ filledQuantity: item.filledQuantity,
560
+ averageFillPrice: item.averageFillPrice ?? undefined,
561
+ tradingMode,
562
+ // Optional on the model, so an unrecognized value is simply absent — there
563
+ // is no honest default to invent, and no reason to lose the order over it.
564
+ timeInForce: asKnown(TIMES_IN_FORCE, item.timeInForce),
565
+ createdAt: item.createdAt,
566
+ updatedAt: item.updatedAt ?? undefined,
567
+ marginAccountId: item.marginAccountId ?? undefined,
568
+ positionId: item.positionId ?? undefined,
569
+ leverage: item.leverage ?? undefined,
570
+ reduceOnly: item.reduceOnly,
571
+ ...(item.clientOrderId === undefined ? {} : { clientOrderId: item.clientOrderId }),
572
+ // Carried, not dropped: it is what the merge ranks this row by, and a row
573
+ // whose state came from the snapshot while its version stayed at the held
574
+ // value would be internally inconsistent — reporting a step it does not
575
+ // describe. Absent stays absent; absent means unknown, never zero.
576
+ ...(item.version === undefined ? {} : { version: item.version }),
577
+ };
578
+ }
579
+ /**
580
+ * Apply a subscribe-time `orders` snapshot to the current list.
581
+ *
582
+ * The snapshot carries RESTING orders only, so — unlike the balances one — it
583
+ * is not the whole list this hook shows. Replacing wholesale would erase every
584
+ * filled or cancelled order the user just placed, so it reconciles through
585
+ * the same version-first ordering as {@link mergeOrders}, with its cap applied
586
+ * by age.
587
+ *
588
+ * It IS complete for the persisted resting set, so absence identifies rows that
589
+ * need resolution — but it is not itself a terminal-state tombstone. On a fast
590
+ * reconnect persistence may lag a newer resting event retained from the prior
591
+ * connection, while an order that really filled or was cancelled during the
592
+ * outage is absent for the same reason: it no longer rests. The pure merge
593
+ * therefore keeps omitted resting rows and reports their ids so the caller can
594
+ * issue the targeted `getOrder` required by the shared order contract. That
595
+ * point read is cache-first and returns the state/version that can be ranked.
596
+ * Terminal rows are kept exactly as before: the snapshot never carried them,
597
+ * so their absence says nothing.
598
+ *
599
+ * A snapshot row is ranked against the order already held on `version`. The
600
+ * snapshot wins when its version is equal or higher, because it is the final
601
+ * state for that sequencer step; a lower version loses. A versioned local row
602
+ * also wins over an unversioned snapshot: the latter cannot be ranked and may
603
+ * be persisted state lagging the previous connection's live stream. When both
604
+ * sides are unversioned, the pre-counter fallback keeps the snapshot's original
605
+ * precedence instead of comparing incompatible timestamps; that also permits a
606
+ * validly-null `updatedAt`.
607
+ *
608
+ * A snapshot row is LAYERED over the order it already knows rather than
609
+ * replacing it. `OrderSnapshotItem` is not a whole `Order`: it carries no
610
+ * `postOnly`, `expirationDate`, `positionSide`, `terminalReason` or fee
611
+ * aggregates. Substituting a winning snapshot row would otherwise erase every
612
+ * one of those — including `postOnly`, where absence is never evidence of
613
+ * `false`. Spreading the row over the existing order lets the snapshot own
614
+ * exactly the fields it describes and leaves the rest standing.
615
+ *
616
+ * The fee aggregates are the one part that can then go stale rather than
617
+ * missing: they accumulate with fills, and a fill this connection never saw
618
+ * moves them. So a snapshot row that reports MORE filled quantity than the row
619
+ * it supersedes schedules the debounced REST re-read, which is the only source
620
+ * for them. A snapshot that changes nothing about the fill state schedules
621
+ * nothing, so a quiet reconnect still costs no request.
622
+ *
623
+ * `hasUndescribableRow` reports rows this version declined, `hasOfflineFill`
624
+ * rows whose fills this connection never saw, and `missingRestingOrderIds`
625
+ * names snapshot omissions that need point reads. The first two let the caller
626
+ * schedule the debounced list re-read; the last must not use a list page because
627
+ * an old order can fall outside it.
628
+ *
629
+ * Pure, and called with the current list read from a ref rather than from
630
+ * inside a state updater — an updater must stay free of side effects, and its
631
+ * signals exist precisely to drive those reads.
632
+ */
633
+ export function applyOrderSnapshot(current, items, limit) {
634
+ const { rows, hasUndescribableRow } = snapshotOrderRows(items);
635
+ const known = new Map(current.map((row) => [row.id, row]));
636
+ // Layered, not substituted: the row's own keys win, and every field it does
637
+ // not carry survives from the order already held.
638
+ //
639
+ // Ranked first, though. A reconnect does NOT recreate this hook's effect, so
640
+ // the list still holds state applied from the previous connection's live
641
+ // stream — while the snapshot is read from persisted state, which can lag
642
+ // that stream. An unconditional win would walk such a row backwards, which is
643
+ // exactly what the shared `version` counter exists to prevent.
644
+ const carried = rows.flatMap((row) => {
645
+ const existing = known.get(row.id);
646
+ if (!existing)
647
+ return [row];
648
+ const ranked = supersedes(row, existing);
649
+ // Unranked means neither side carries a version, which is the pre-0XM-2440
650
+ // world: the snapshot is still the authority on resting state there, so it
651
+ // keeps its original precedence.
652
+ if (ranked === false)
653
+ return [];
654
+ return [{ ...existing, ...row }];
655
+ });
656
+ // Core validates the frame and every order id before delivery. A row this
657
+ // hook cannot describe because it carries a future enum value is still known
658
+ // to be PRESENT, but it does not make the other absences ambiguous. Build the
659
+ // presence set from raw items so the declined row itself is not misclassified
660
+ // as missing, then resolve every distinct held resting row the frame omitted.
661
+ const snapshotIds = new Set(items.map((row) => row.orderId));
662
+ const missingRestingOrderIds = current.filter((row) => isRestingStatus(row.status) && !snapshotIds.has(row.id)).map((row) => row.id);
663
+ // Upsert, then apply the same recency cap `mergeOrders` uses, so a snapshot
664
+ // row and a live-only row compete for the cap on age rather than on origin.
665
+ const byId = new Map(current.map((row) => [row.id, row]));
666
+ for (const row of carried)
667
+ byId.set(row.id, row);
668
+ const orders = [...byId.values()].sort((a, b) => createdAtMs(b) - createdAtMs(a)).slice(0, limit);
669
+ return {
670
+ orders,
671
+ hasUndescribableRow,
672
+ hasOfflineFill: carried.some((row) => filledOffline(known.get(row.id), row)),
673
+ missingRestingOrderIds,
674
+ };
675
+ }
676
+ /**
677
+ * Whether a snapshot row reports fills the held order has not seen.
678
+ *
679
+ * The fee aggregates the snapshot cannot carry move with fills, so a changed
680
+ * fill total is the signal that they are now stale and need the REST read. A
681
+ * row whose fill state is unchanged leaves them accurate, and a new order the
682
+ * list did not hold has no aggregates to have gone stale.
683
+ *
684
+ * Called only for rows that won the version comparison. Compared as normalized
685
+ * decimal STRINGS, not as numbers. `filledQuantity` only ever grows for an
686
+ * accepted incoming row, so "differs" and "grew" are the same question, and
687
+ * answering it without `parseFloat` keeps it exact — a float comparison would
688
+ * be inexact past ~15 significant digits, and this repo does not do financial
689
+ * comparisons in floating point. Normalizing is what makes the string compare
690
+ * sound: the held row can come from REST and the incoming one from the wire,
691
+ * and the same value may be rendered `1` by one and `1.000` by the other.
692
+ *
693
+ * An unparseable quantity on either side is treated as no evidence rather than
694
+ * as a difference, so a malformed decimal cannot drive a read on every frame.
695
+ */
696
+ function filledOffline(existing, incoming) {
697
+ if (!existing)
698
+ return false;
699
+ const before = normalizedDecimal(existing.filledQuantity);
700
+ const after = normalizedDecimal(incoming.filledQuantity);
701
+ return before !== null && after !== null && before !== after;
702
+ }
703
+ /**
704
+ * A decimal string in a single canonical form, or `null` if it is not one.
705
+ *
706
+ * Strips a leading `+`, redundant leading zeros and trailing fractional zeros,
707
+ * so two renderings of one value compare equal.
708
+ */
709
+ function normalizedDecimal(value) {
710
+ const match = value.trim().match(/^([+-]?)(\d+)(?:\.(\d*))?$/);
711
+ if (!match)
712
+ return null;
713
+ // Read by index with a fallback rather than destructured: under
714
+ // `noUncheckedIndexedAccess` a capture group is `string | undefined`, even
715
+ // one the pattern makes mandatory.
716
+ const sign = match[1] ?? "";
717
+ const integer = (match[2] ?? "").replace(/^0+(?=\d)/, "");
718
+ const decimals = (match[3] ?? "").replace(/0+$/, "");
719
+ const magnitude = decimals ? `${integer}.${decimals}` : integer;
720
+ // `-0` and `0` are the same quantity.
721
+ return magnitude === "0" ? "0" : `${sign === "-" ? "-" : ""}${magnitude}`;
722
+ }
723
+ /**
724
+ * The statuses the subscribe-time snapshot treats as resting.
725
+ *
726
+ * Mirrors the server's own `SNAPSHOT_RESTING_ORDER_STATUSES` — an order in one
727
+ * of these is still working on the book, so the snapshot carries it and its
728
+ * absence therefore means it stopped resting. Every other status is terminal
729
+ * or post-trade, never carried, so absence says nothing about it.
730
+ *
731
+ * The two sets must not drift: a status the server started treating as resting
732
+ * but this one does not would put its orders back in the stale-forever case
733
+ * this drop exists to fix, and one this set claims but the server does not
734
+ * would delete rows on an absence that means nothing. Exported so
735
+ * `useUserOrders.test.ts` can pin it against the Rust constant directly.
736
+ *
737
+ * A `Record<..., true>` over a subset of `OrderStatus`, read through
738
+ * `Object.hasOwn`, for the same reasons as {@link asKnown}: no prototype walk,
739
+ * and the union's own membership is checked by the compiler.
740
+ */
741
+ export const SNAPSHOT_RESTING_STATUSES = { SUBMITTED: true, PARTIALLY_FILLED: true };
742
+ function isRestingStatus(status) {
743
+ return Object.hasOwn(SNAPSHOT_RESTING_STATUSES, status);
744
+ }
745
+ /**
746
+ * Map snapshot rows onto the REST model, reporting whether any was declined.
747
+ *
748
+ * Split out of {@link applyOrderSnapshot} because it needs no view of the
749
+ * current list, which makes the mapping — and the decline rule that governs it
750
+ * — testable on its own.
751
+ */
752
+ export function snapshotOrderRows(items) {
753
+ const mapped = items.map(orderFromSnapshotItem);
754
+ return { rows: mapped.filter((row) => row !== null), hasUndescribableRow: mapped.some((row) => row === null) };
755
+ }
756
+ /**
757
+ * Reconcile a REST read against live state, ranking on `version` first.
283
758
  *
284
759
  * The resync runs with the subscription open, so `fetched` may already be stale
285
760
  * by the time it lands. Three cases:
286
761
  *
287
- * - In both: keep whichever `updatedAt` is later, so a fill that arrived over
288
- * the socket mid-request is not rolled back by an older REST row. Ties keep
289
- * the live row, which cannot be older than the response.
762
+ * - In both: when either row has `version`, the versioned side wins; when both
763
+ * do, the incoming REST row wins on `>=` because it is that step's final
764
+ * state. Only when neither has a version does `updatedAt` arbitrate.
290
765
  * - Live only: keep it. Either it was placed over the socket after the read
291
766
  * started, or it is an older row the page no longer reaches — the two are
292
767
  * indistinguishable here, which is why the cap is applied by age below
@@ -304,13 +779,30 @@ export function mergeOrders(current, fetched, limit) {
304
779
  const live = currentById.get(incoming.id);
305
780
  if (!live)
306
781
  return incoming;
782
+ // Version first: it is the only field that orders these two soundly.
783
+ const ranked = supersedes(incoming, live);
784
+ if (ranked !== "unranked")
785
+ return ranked ? keepingPostOnly(incoming, live) : live;
307
786
  const liveAt = updatedAtMs(live);
308
787
  const incomingAt = updatedAtMs(incoming);
309
788
  // An unparseable timestamp on either side is not evidence of staleness, so
310
789
  // prefer the live row rather than silently rolling it back.
311
790
  if (Number.isNaN(liveAt) || Number.isNaN(incomingAt))
312
791
  return live;
313
- return liveAt >= incomingAt ? live : incoming;
792
+ if (liveAt > incomingAt)
793
+ return live;
794
+ if (liveAt < incomingAt)
795
+ return incoming;
796
+ // A TIE keeps the live row's own state — it cannot be older than the
797
+ // response, and at second resolution two genuinely different states can
798
+ // share a timestamp — but takes the REST-ONLY fields from the response.
799
+ // No WebSocket frame carries those, so the live row's copies are whatever
800
+ // an older read left behind, and a snapshot-derived row has none at all.
801
+ // Without this the post-snapshot repair could never land: a snapshot and a
802
+ // REST read of one order report the SAME `updated_at`, both projecting
803
+ // `orders.updated_at`, so the read returned the fee aggregates and the tie
804
+ // then discarded them.
805
+ return withRestOnlyFields(live, incoming);
314
806
  });
315
807
  const liveOnly = current.filter((order) => !fetchedIds.has(order.id));
316
808
  // Cap by RECENCY, not by side. Prepending every live-only row and slicing
@@ -371,6 +863,9 @@ export function orderFromEvent(event) {
371
863
  // post-only order most often produces and disagree with REST. Only a real
372
864
  // boolean is taken: anything else is not evidence the order was post-only.
373
865
  postOnly: typeof data.postOnly === "boolean" ? data.postOnly : undefined,
866
+ // The local revision the merge ranks against. Without it every REST
867
+ // response outranks live state by default and the stream can be undone.
868
+ version: typeof data.version === "number" ? data.version : undefined,
374
869
  createdAt: event.timestamp,
375
870
  updatedAt: event.timestamp,
376
871
  };
@@ -432,12 +927,78 @@ export function updateOrderFromEvent(order, event) {
432
927
  if ("quantity" in data && data.quantity) {
433
928
  quantity = data.quantity;
434
929
  }
435
- return {
930
+ const updated = {
436
931
  ...order,
437
932
  status: newStatus,
438
933
  quantity,
439
934
  filledQuantity: filledQuantity,
440
935
  averageFillPrice: avgFillPrice,
441
936
  updatedAt: event.timestamp,
937
+ // Advanced only when the event reports one. Absent means unknown, never
938
+ // zero, so it must not erase a revision already known — spreading `order`
939
+ // above is what keeps that value when this event carries none.
940
+ ...(typeof data.version === "number" ? { version: data.version } : {}),
442
941
  };
942
+ return liveEventSupersedes(order, updated, typeof data.version === "number" ? data.version : undefined) ? updated : order;
943
+ }
944
+ /**
945
+ * Whether a live event can advance the row currently held.
946
+ *
947
+ * Snapshots and REST point reads can land before an older buffered event. A
948
+ * lower event version — or an absent one against known-version state — cannot
949
+ * overwrite that authoritative row. Equal versions need finer arbitration:
950
+ * one sequencer step can emit `OrderPlaced` and then one or more cumulative
951
+ * fill events, so a later same-step event is accepted only while the order is
952
+ * still resting and its cumulative fill/status does not move backwards.
953
+ */
954
+ function liveEventSupersedes(current, incoming, incomingVersion) {
955
+ if (incomingVersion === undefined)
956
+ return current.version === undefined;
957
+ if (current.version === undefined)
958
+ return true;
959
+ if (incomingVersion !== current.version)
960
+ return incomingVersion > current.version;
961
+ // A REST/snapshot terminal row is the step's final state. No event from the
962
+ // same step can advance it, but an intermediate event could roll it back.
963
+ if (!isRestingStatus(current.status))
964
+ return false;
965
+ const fillOrder = compareDecimalValues(incoming.filledQuantity, current.filledQuantity);
966
+ if (fillOrder === null) {
967
+ // Different unreadable values provide no evidence of forward progress.
968
+ if (incoming.filledQuantity !== current.filledQuantity)
969
+ return false;
970
+ }
971
+ else if (fillOrder !== 0) {
972
+ return fillOrder > 0;
973
+ }
974
+ // With an unchanged cumulative fill, SUBMITTED is the only resting state
975
+ // behind PARTIALLY_FILLED. Terminal events remain valid forward progress.
976
+ return current.status !== "PARTIALLY_FILLED" || incoming.status !== "SUBMITTED";
977
+ }
978
+ /** Exact decimal ordering without floating-point loss. */
979
+ function compareDecimalValues(left, right) {
980
+ const normalizedLeft = normalizedDecimal(left);
981
+ const normalizedRight = normalizedDecimal(right);
982
+ if (normalizedLeft === null || normalizedRight === null)
983
+ return null;
984
+ if (normalizedLeft === normalizedRight)
985
+ return 0;
986
+ const leftNegative = normalizedLeft.startsWith("-");
987
+ const rightNegative = normalizedRight.startsWith("-");
988
+ if (leftNegative !== rightNegative)
989
+ return leftNegative ? -1 : 1;
990
+ const [leftInteger = "", leftFraction = ""] = (leftNegative ? normalizedLeft.slice(1) : normalizedLeft).split(".");
991
+ const [rightInteger = "", rightFraction = ""] = (rightNegative ? normalizedRight.slice(1) : normalizedRight).split(".");
992
+ let magnitudeOrder;
993
+ if (leftInteger.length !== rightInteger.length) {
994
+ magnitudeOrder = leftInteger.length < rightInteger.length ? -1 : 1;
995
+ }
996
+ else if (leftInteger !== rightInteger) {
997
+ magnitudeOrder = leftInteger < rightInteger ? -1 : 1;
998
+ }
999
+ else {
1000
+ const scale = Math.max(leftFraction.length, rightFraction.length);
1001
+ magnitudeOrder = leftFraction.padEnd(scale, "0") < rightFraction.padEnd(scale, "0") ? -1 : 1;
1002
+ }
1003
+ return leftNegative ? (magnitudeOrder === -1 ? 1 : -1) : magnitudeOrder;
443
1004
  }