@parity/product-sdk-host 0.16.0 → 0.17.0

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.
@@ -61,6 +61,38 @@ const JSON_RPC_METHOD_NOT_FOUND = -32601;
61
61
  /** A `chainHead_v1_followEvent` payload (loosely typed — consumed by PAPI's substrate-client). */
62
62
  type FollowEvent = { event: string } & Record<string, unknown>;
63
63
 
64
+ type BufferedOperation = {
65
+ announced: boolean;
66
+ items: RemoteChainHeadFollowItem[];
67
+ };
68
+
69
+ /** Return the operation id carried by an operation follow item. */
70
+ function followOperationId(item: RemoteChainHeadFollowItem): string | undefined {
71
+ switch (item.tag) {
72
+ case "OperationBodyDone":
73
+ case "OperationCallDone":
74
+ case "OperationStorageItems":
75
+ case "OperationStorageDone":
76
+ case "OperationWaitingForContinue":
77
+ case "OperationInaccessible":
78
+ case "OperationError":
79
+ return item.value.operationId;
80
+ default:
81
+ return undefined;
82
+ }
83
+ }
84
+
85
+ /** Whether this item closes its operation. */
86
+ function isTerminalOperationItem(item: RemoteChainHeadFollowItem): boolean {
87
+ return (
88
+ item.tag === "OperationBodyDone" ||
89
+ item.tag === "OperationCallDone" ||
90
+ item.tag === "OperationStorageDone" ||
91
+ item.tag === "OperationInaccessible" ||
92
+ item.tag === "OperationError"
93
+ );
94
+ }
95
+
64
96
  /** Map a JSON-RPC storage query-type string to the truapi `StorageQueryType` tag. */
65
97
  const STORAGE_TYPE_MAP: Record<string, StorageQueryType> = {
66
98
  value: "Value",
@@ -194,6 +226,8 @@ export function createHostPapiProvider(
194
226
  return (onMessage: (message: JsonRpcMessage) => void): JsonRpcConnection => {
195
227
  const activeFollows = new Map<string, TransportSubscription>();
196
228
  const activeBroadcasts = new Set<string>();
229
+ const followOperations = new Map<string, Map<string, BufferedOperation>>();
230
+ const pendingOperationStarts = new Map<string, number>();
197
231
 
198
232
  function sendJsonRpcResponse(id: JsonRpcRequest["id"], result: unknown): void {
199
233
  onMessage({ jsonrpc: "2.0", id, result } as JsonRpcMessage);
@@ -209,6 +243,96 @@ export function createHostPapiProvider(
209
243
  } as JsonRpcMessage);
210
244
  }
211
245
 
246
+ function forwardFollowItem(
247
+ followSubscriptionId: string,
248
+ item: RemoteChainHeadFollowItem,
249
+ ): void {
250
+ const operationId = followOperationId(item);
251
+ if (operationId === undefined) {
252
+ sendFollowEvent(followSubscriptionId, convertFollowEventToJsonRpc(item));
253
+ return;
254
+ }
255
+
256
+ const operations = followOperations.get(followSubscriptionId);
257
+ if (!operations) return;
258
+
259
+ let operation = operations.get(operationId);
260
+ if (!operation) {
261
+ // A missing entry also means the operation already ended — the spec and
262
+ // PAPI's cancel path still emit then. With no start outstanding nothing
263
+ // can announce those, so buffering would strand them until unfollow.
264
+ if ((pendingOperationStarts.get(followSubscriptionId) ?? 0) === 0) {
265
+ sendFollowEvent(followSubscriptionId, convertFollowEventToJsonRpc(item));
266
+ return;
267
+ }
268
+ operation = { announced: false, items: [] };
269
+ operations.set(operationId, operation);
270
+ }
271
+ if (!operation.announced) {
272
+ operation.items.push(item);
273
+ return;
274
+ }
275
+
276
+ sendFollowEvent(followSubscriptionId, convertFollowEventToJsonRpc(item));
277
+ if (isTerminalOperationItem(item)) {
278
+ operations.delete(operationId);
279
+ }
280
+ }
281
+
282
+ function sendOperationStartedResponse(
283
+ id: JsonRpcRequest["id"],
284
+ followSubscriptionId: string,
285
+ result: OperationStartedResult,
286
+ ): void {
287
+ // The JSON-RPC response must be observable before any event naming its
288
+ // operation. TrUAPI request and subscription frames are independent,
289
+ // so a fast operation can complete before this request resolves.
290
+ sendJsonRpcResponse(id, convertOperationResultToJsonRpc(result));
291
+ if (result.tag !== "Started") return;
292
+
293
+ const operations = followOperations.get(followSubscriptionId);
294
+ if (!operations) return;
295
+
296
+ const operationId = result.value.operationId;
297
+ let operation = operations.get(operationId);
298
+ if (!operation) {
299
+ operation = { announced: true, items: [] };
300
+ operations.set(operationId, operation);
301
+ return;
302
+ }
303
+
304
+ operation.announced = true;
305
+ const pendingItems = operation.items;
306
+ operation.items = [];
307
+ for (const item of pendingItems) {
308
+ forwardFollowItem(followSubscriptionId, item);
309
+ }
310
+ }
311
+
312
+ // Both arms must clear the count: a failed start never sends `Started`, and a
313
+ // stuck count buffers forever.
314
+ function startOperationRequest(id: JsonRpcRequest["id"], followSubscriptionId: string) {
315
+ const pending = pendingOperationStarts.get(followSubscriptionId);
316
+ if (pending !== undefined) {
317
+ pendingOperationStarts.set(followSubscriptionId, pending + 1);
318
+ }
319
+ const settle = () => {
320
+ const outstanding = pendingOperationStarts.get(followSubscriptionId);
321
+ if (outstanding === undefined) return;
322
+ pendingOperationStarts.set(followSubscriptionId, Math.max(0, outstanding - 1));
323
+ };
324
+ return {
325
+ ok: (response: { operation: OperationStartedResult }) => {
326
+ settle();
327
+ sendOperationStartedResponse(id, followSubscriptionId, response.operation);
328
+ },
329
+ err: (error: unknown) => {
330
+ settle();
331
+ hostError(id)(error);
332
+ },
333
+ };
334
+ }
335
+
212
336
  /** Reject an inbound request with the host's error reason as the JSON-RPC message. */
213
337
  const hostError = (id: JsonRpcRequest["id"]) => (error: unknown) =>
214
338
  sendJsonRpcError(id, JSON_RPC_INTERNAL_ERROR, formatHostError(error));
@@ -234,8 +358,10 @@ export function createHostPapiProvider(
234
358
  ) => {
235
359
  if (item.tag === "Stop" && activeFollows.delete(followSubscriptionId)) {
236
360
  ref.handle?.unsubscribe();
361
+ followOperations.delete(followSubscriptionId);
362
+ pendingOperationStarts.delete(followSubscriptionId);
237
363
  }
238
- sendFollowEvent(followSubscriptionId, convertFollowEventToJsonRpc(item));
364
+ forwardFollowItem(followSubscriptionId, item);
239
365
  };
240
366
  ref.handle = subscribeWithInterrupt(
241
367
  chain.followHeadSubscribe({ request: { genesisHash, withRuntime } }),
@@ -261,11 +387,15 @@ export function createHostPapiProvider(
261
387
  // A transport interrupt/close ends the stream without a Stop
262
388
  // item; synthesize one so the consumer refollows.
263
389
  ref.handle.onInterrupt(() => {
390
+ followOperations.delete(followSubscriptionId);
391
+ pendingOperationStarts.delete(followSubscriptionId);
264
392
  if (activeFollows.delete(followSubscriptionId)) {
265
393
  sendFollowEvent(followSubscriptionId, { event: "stop" });
266
394
  }
267
395
  });
268
396
  activeFollows.set(followSubscriptionId, ref.handle);
397
+ followOperations.set(followSubscriptionId, new Map());
398
+ pendingOperationStarts.set(followSubscriptionId, 0);
269
399
  sendJsonRpcResponse(id, followSubscriptionId);
270
400
  for (const item of pendingItems) {
271
401
  forwardItem(followSubscriptionId, item);
@@ -279,6 +409,8 @@ export function createHostPapiProvider(
279
409
  follow.unsubscribe();
280
410
  activeFollows.delete(followSubId);
281
411
  }
412
+ followOperations.delete(followSubId);
413
+ pendingOperationStarts.delete(followSubId);
282
414
  sendJsonRpcResponse(id, null);
283
415
  break;
284
416
  }
@@ -294,16 +426,10 @@ export function createHostPapiProvider(
294
426
  }
295
427
  case "chainHead_v1_body": {
296
428
  const [followSubscriptionId, hash] = params as [string, HexString];
429
+ const bodyStart = startOperationRequest(id, followSubscriptionId);
297
430
  chain
298
431
  .getHeadBody({ genesisHash, followSubscriptionId, hash })
299
- .match(
300
- (response) =>
301
- sendJsonRpcResponse(
302
- id,
303
- convertOperationResultToJsonRpc(response.operation),
304
- ),
305
- hostError(id),
306
- );
432
+ .match(bodyStart.ok, bodyStart.err);
307
433
  break;
308
434
  }
309
435
  case "chainHead_v1_storage": {
@@ -317,6 +443,7 @@ export function createHostPapiProvider(
317
443
  key: item.key,
318
444
  queryType: convertStorageType(item.type),
319
445
  }));
446
+ const storageStart = startOperationRequest(id, followSubscriptionId);
320
447
  chain
321
448
  .getHeadStorage({
322
449
  genesisHash,
@@ -330,14 +457,7 @@ export function createHostPapiProvider(
330
457
  // (`null.startsWith`). Coerce `null` → `undefined`.
331
458
  childTrie: childTrie ?? undefined,
332
459
  })
333
- .match(
334
- (response) =>
335
- sendJsonRpcResponse(
336
- id,
337
- convertOperationResultToJsonRpc(response.operation),
338
- ),
339
- hostError(id),
340
- );
460
+ .match(storageStart.ok, storageStart.err);
341
461
  break;
342
462
  }
343
463
  case "chainHead_v1_call": {
@@ -347,6 +467,7 @@ export function createHostPapiProvider(
347
467
  string,
348
468
  HexString,
349
469
  ];
470
+ const callStart = startOperationRequest(id, followSubscriptionId);
350
471
  chain
351
472
  .callHead({
352
473
  genesisHash,
@@ -355,14 +476,7 @@ export function createHostPapiProvider(
355
476
  function: fn,
356
477
  callParameters,
357
478
  })
358
- .match(
359
- (response) =>
360
- sendJsonRpcResponse(
361
- id,
362
- convertOperationResultToJsonRpc(response.operation),
363
- ),
364
- hostError(id),
365
- );
479
+ .match(callStart.ok, callStart.err);
366
480
  break;
367
481
  }
368
482
  case "chainHead_v1_unpin": {
@@ -387,7 +501,10 @@ export function createHostPapiProvider(
387
501
  const [followSubscriptionId, operationId] = params as [string, string];
388
502
  chain
389
503
  .stopHeadOperation({ genesisHash, followSubscriptionId, operationId })
390
- .match(() => sendJsonRpcResponse(id, null), hostError(id));
504
+ .match(() => {
505
+ followOperations.get(followSubscriptionId)?.delete(operationId);
506
+ sendJsonRpcResponse(id, null);
507
+ }, hostError(id));
391
508
  break;
392
509
  }
393
510
  case "chainSpec_v1_genesisHash": {
@@ -467,6 +584,8 @@ export function createHostPapiProvider(
467
584
  handle.unsubscribe();
468
585
  }
469
586
  activeFollows.clear();
587
+ followOperations.clear();
588
+ pendingOperationStarts.clear();
470
589
  for (const operationId of activeBroadcasts) {
471
590
  // Fire-and-forget: the transport may already be torn down.
472
591
  chain.stopTransaction({ genesisHash, operationId }).match(
@@ -491,6 +610,8 @@ if (import.meta.vitest) {
491
610
  responses?: Record<string, unknown>;
492
611
  /** Methods named here resolve to their `.match` error arm carrying the given value. */
493
612
  errors?: Record<string, unknown>;
613
+ /** Capture selected successful matches so tests can resolve them after follow events. */
614
+ deferMatch?: (method: string, resolve: () => unknown) => boolean;
494
615
  /** Unsubscribe spy used by the follow subscription (defaults to a fresh `vi.fn()`). */
495
616
  unsubscribe?: () => void;
496
617
  /** Item emitted synchronously while the transport subscription starts. */
@@ -503,16 +624,20 @@ if (import.meta.vitest) {
503
624
  /** Transport request id assigned to the follow subscription. */
504
625
  subscriptionId?: string;
505
626
  }) {
506
- const okMatch = (value: unknown) => ({
507
- match: (ok: (v: unknown) => unknown, _err: (e: unknown) => unknown) => ok(value),
508
- });
509
627
  const errMatch = (error: unknown) => ({
510
628
  match: (_ok: (v: unknown) => unknown, err: (e: unknown) => unknown) => err(error),
511
629
  });
512
630
  const method = (name: string, response: unknown) => (args: unknown) => {
513
631
  opts.onCall?.(name, args);
514
632
  const errors = opts.errors ?? {};
515
- return name in errors ? errMatch(errors[name]) : okMatch(response);
633
+ if (name in errors) return errMatch(errors[name]);
634
+ return {
635
+ match: (ok: (value: unknown) => unknown, _err: (error: unknown) => unknown) => {
636
+ const resolve = () => ok(response);
637
+ if (opts.deferMatch?.(name, resolve)) return undefined;
638
+ return resolve();
639
+ },
640
+ };
516
641
  };
517
642
  return {
518
643
  chain: {
@@ -539,13 +664,22 @@ if (import.meta.vitest) {
539
664
  "getHeadHeader",
540
665
  opts.responses?.getHeadHeader ?? { header: "0x01" },
541
666
  ),
542
- getHeadBody: method("getHeadBody", {
543
- operation: { tag: "Started", value: { operationId: "op1" } },
544
- }),
545
- getHeadStorage: method("getHeadStorage", { operation: { tag: "LimitReached" } }),
546
- callHead: method("callHead", {
547
- operation: { tag: "Started", value: { operationId: "op2" } },
548
- }),
667
+ getHeadBody: method(
668
+ "getHeadBody",
669
+ opts.responses?.getHeadBody ?? {
670
+ operation: { tag: "Started", value: { operationId: "op1" } },
671
+ },
672
+ ),
673
+ getHeadStorage: method(
674
+ "getHeadStorage",
675
+ opts.responses?.getHeadStorage ?? { operation: { tag: "LimitReached" } },
676
+ ),
677
+ callHead: method(
678
+ "callHead",
679
+ opts.responses?.callHead ?? {
680
+ operation: { tag: "Started", value: { operationId: "op2" } },
681
+ },
682
+ ),
549
683
  unpinHead: method("unpinHead", undefined),
550
684
  continueHead: method("continueHead", undefined),
551
685
  stopHeadOperation: method("stopHeadOperation", undefined),
@@ -628,6 +762,205 @@ if (import.meta.vitest) {
628
762
  ]);
629
763
  });
630
764
 
765
+ test("buffers a call completion until after its operation-start response", () => {
766
+ let observer:
767
+ | { next: (i: unknown) => void; error: (e: unknown) => void; complete: () => void }
768
+ | undefined;
769
+ let resolveCall: (() => unknown) | undefined;
770
+ const client = makeFakeClient({
771
+ responses: {
772
+ callHead: { operation: { tag: "Started", value: { operationId: "op-call" } } },
773
+ },
774
+ captureObserver: (value) => {
775
+ observer = value;
776
+ },
777
+ deferMatch: (method, resolve) => {
778
+ if (method !== "callHead") return false;
779
+ resolveCall = resolve;
780
+ return true;
781
+ },
782
+ });
783
+ const messages: JsonRpcMessage[] = [];
784
+ const conn = createHostPapiProvider(client, "0xfeed")((message) => messages.push(message));
785
+ conn.send({ jsonrpc: "2.0", id: 1, method: "chainHead_v1_follow", params: [false] });
786
+ messages.length = 0;
787
+
788
+ conn.send({
789
+ jsonrpc: "2.0",
790
+ id: 2,
791
+ method: "chainHead_v1_call",
792
+ params: ["p:41", "0xhash", "Test_call", "0x"],
793
+ });
794
+ observer?.next({
795
+ tag: "OperationCallDone",
796
+ value: { operationId: "op-call", output: "0x1234" },
797
+ });
798
+ expect(messages).toEqual([]);
799
+
800
+ resolveCall?.();
801
+ expect(messages).toEqual([
802
+ {
803
+ jsonrpc: "2.0",
804
+ id: 2,
805
+ result: { result: "started", operationId: "op-call" },
806
+ },
807
+ {
808
+ jsonrpc: "2.0",
809
+ method: "chainHead_v1_followEvent",
810
+ params: {
811
+ subscription: "p:41",
812
+ result: {
813
+ event: "operationCallDone",
814
+ operationId: "op-call",
815
+ output: "0x1234",
816
+ },
817
+ },
818
+ },
819
+ ]);
820
+ });
821
+
822
+ test("preserves buffered storage item order after announcing the operation", () => {
823
+ let observer:
824
+ | { next: (i: unknown) => void; error: (e: unknown) => void; complete: () => void }
825
+ | undefined;
826
+ let resolveStorage: (() => unknown) | undefined;
827
+ const client = makeFakeClient({
828
+ responses: {
829
+ getHeadStorage: {
830
+ operation: { tag: "Started", value: { operationId: "op-storage" } },
831
+ },
832
+ },
833
+ captureObserver: (value) => {
834
+ observer = value;
835
+ },
836
+ deferMatch: (method, resolve) => {
837
+ if (method !== "getHeadStorage") return false;
838
+ resolveStorage = resolve;
839
+ return true;
840
+ },
841
+ });
842
+ const messages: JsonRpcMessage[] = [];
843
+ const conn = createHostPapiProvider(client, "0xfeed")((message) => messages.push(message));
844
+ conn.send({ jsonrpc: "2.0", id: 1, method: "chainHead_v1_follow", params: [false] });
845
+ messages.length = 0;
846
+
847
+ conn.send({
848
+ jsonrpc: "2.0",
849
+ id: 3,
850
+ method: "chainHead_v1_storage",
851
+ params: ["p:41", "0xhash", [{ key: "0x01", type: "value" }], null],
852
+ });
853
+ observer?.next({
854
+ tag: "OperationStorageItems",
855
+ value: {
856
+ operationId: "op-storage",
857
+ items: [{ key: "0x01", value: "0xabcd" }],
858
+ },
859
+ });
860
+ observer?.next({
861
+ tag: "OperationStorageDone",
862
+ value: { operationId: "op-storage" },
863
+ });
864
+ expect(messages).toEqual([]);
865
+
866
+ resolveStorage?.();
867
+ expect(
868
+ messages.map((message) => ("id" in message ? message.id : message.params.result.event)),
869
+ ).toEqual([3, "operationStorageItems", "operationStorageDone"]);
870
+ });
871
+
872
+ test("forwards an operation event that arrives after the operation ended", () => {
873
+ let observer:
874
+ | { next: (i: unknown) => void; error: (e: unknown) => void; complete: () => void }
875
+ | undefined;
876
+ const client = makeFakeClient({
877
+ responses: {
878
+ callHead: { operation: { tag: "Started", value: { operationId: "op-late" } } },
879
+ },
880
+ captureObserver: (value) => {
881
+ observer = value;
882
+ },
883
+ });
884
+ const messages: JsonRpcMessage[] = [];
885
+ const conn = createHostPapiProvider(client, "0xfeed")((message) => messages.push(message));
886
+ conn.send({ jsonrpc: "2.0", id: 1, method: "chainHead_v1_follow", params: [false] });
887
+ conn.send({
888
+ jsonrpc: "2.0",
889
+ id: 2,
890
+ method: "chainHead_v1_call",
891
+ params: ["p:41", "0xhash", "Test_call", "0x"],
892
+ });
893
+ observer?.next({
894
+ tag: "OperationCallDone",
895
+ value: { operationId: "op-late", output: "0x1" },
896
+ });
897
+ messages.length = 0;
898
+
899
+ observer?.next({
900
+ tag: "OperationStorageItems",
901
+ value: { operationId: "op-late", items: [{ key: "0x01", value: "0xabcd" }] },
902
+ });
903
+
904
+ expect(messages).toEqual([
905
+ {
906
+ jsonrpc: "2.0",
907
+ method: "chainHead_v1_followEvent",
908
+ params: {
909
+ subscription: "p:41",
910
+ result: {
911
+ event: "operationStorageItems",
912
+ operationId: "op-late",
913
+ items: [{ key: "0x01", value: "0xabcd" }],
914
+ },
915
+ },
916
+ },
917
+ ]);
918
+ });
919
+
920
+ test("a failed operation start releases the follow's buffering", () => {
921
+ let observer:
922
+ | { next: (i: unknown) => void; error: (e: unknown) => void; complete: () => void }
923
+ | undefined;
924
+ const client = makeFakeClient({
925
+ errors: { callHead: { reason: "host unavailable" } },
926
+ captureObserver: (value) => {
927
+ observer = value;
928
+ },
929
+ });
930
+ const messages: JsonRpcMessage[] = [];
931
+ const conn = createHostPapiProvider(client, "0xfeed")((message) => messages.push(message));
932
+ conn.send({ jsonrpc: "2.0", id: 1, method: "chainHead_v1_follow", params: [false] });
933
+ conn.send({
934
+ jsonrpc: "2.0",
935
+ id: 2,
936
+ method: "chainHead_v1_call",
937
+ params: ["p:41", "0xhash", "Test_call", "0x"],
938
+ });
939
+ messages.length = 0;
940
+
941
+ // A failed start never sends `Started`. If it left the count raised, every
942
+ // later event for an unknown operation would buffer for the life of the follow.
943
+ observer?.next({
944
+ tag: "OperationStorageItems",
945
+ value: { operationId: "op-unknown", items: [{ key: "0x01", value: "0xabcd" }] },
946
+ });
947
+
948
+ expect(messages).toEqual([
949
+ {
950
+ jsonrpc: "2.0",
951
+ method: "chainHead_v1_followEvent",
952
+ params: {
953
+ subscription: "p:41",
954
+ result: {
955
+ event: "operationStorageItems",
956
+ operationId: "op-unknown",
957
+ items: [{ key: "0x01", value: "0xabcd" }],
958
+ },
959
+ },
960
+ },
961
+ ]);
962
+ });
963
+
631
964
  test("chainSpec_v1_properties parses the JSON-encoded properties string", () => {
632
965
  const client = makeFakeClient({});
633
966
  const provider = createHostPapiProvider(client, "0xfeed");
package/src/testing.ts CHANGED
@@ -26,9 +26,9 @@ import { errAsync, okAsync } from "neverthrow";
26
26
 
27
27
  import type { HostChainIdentifier } from "./chain-discovery.js";
28
28
 
29
- import { setTruApiClient } from "./transport.js";
29
+ import { type HostConnectionStatus, emitConnectionStatus, setTruApiClient } from "./transport.js";
30
30
 
31
- export { setTruApiClient };
31
+ export { setTruApiClient, emitConnectionStatus };
32
32
 
33
33
  /**
34
34
  * The public surface of a generated truapi domain client. `keyof` skips private
@@ -304,6 +304,12 @@ type TestFinishedHook = (fn: () => void) => void;
304
304
  export interface FakeHost extends Disposable {
305
305
  /** The injected fake client (also what `getTruApi()` returns). */
306
306
  client: TrUApiClient;
307
+ /**
308
+ * Push a status to `subscribeConnectionStatus` subscribers, so a product's
309
+ * reconnecting / offline UI can be exercised. `dispose()` already reports
310
+ * `"disconnected"`; use this to drive the states in between.
311
+ */
312
+ emitConnectionStatus(status: HostConnectionStatus): void;
307
313
  /** Clear the override. Idempotent; safe to call more than once. */
308
314
  dispose(): void;
309
315
  /** `using host = createFakeHost()` restores the real client at scope end. */
@@ -355,6 +361,7 @@ export function createFakeHost(options?: CreateFakeTruApiClientOptions): FakeHos
355
361
 
356
362
  return {
357
363
  client,
364
+ emitConnectionStatus,
358
365
  dispose,
359
366
  [Symbol.dispose]: dispose,
360
367
  };