@parity/product-sdk-host 0.16.0 → 0.18.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.
- package/dist/{chain-discovery-nLrPzb3d.d.ts → chain-discovery-BEvu7HaV.d.ts} +42 -5
- package/dist/chunk-PAKEUP2Q.js +89 -0
- package/dist/chunk-PAKEUP2Q.js.map +1 -0
- package/dist/index.d.ts +52 -16
- package/dist/index.js +279 -93
- package/dist/index.js.map +1 -1
- package/dist/testing.d.ts +9 -2
- package/dist/testing.js +3 -2
- package/dist/testing.js.map +1 -1
- package/package.json +4 -4
- package/src/accounts.ts +327 -101
- package/src/chains.ts +10 -3
- package/src/errors.ts +46 -0
- package/src/index.ts +7 -0
- package/src/papi-provider.ts +370 -37
- package/src/testing.ts +9 -2
- package/src/transport.ts +228 -4
- package/src/truapi.ts +139 -3
- package/dist/chunk-GDXSV7JV.js +0 -50
- package/dist/chunk-GDXSV7JV.js.map +0 -1
package/src/index.ts
CHANGED
|
@@ -57,6 +57,11 @@ export type {
|
|
|
57
57
|
export { getHostChainInfo } from "./chain-discovery.js";
|
|
58
58
|
export type { HostChainDiscovery, HostChainIdentifier } from "./chain-discovery.js";
|
|
59
59
|
|
|
60
|
+
// Host-channel connection status — the transport's own signal. Distinct from
|
|
61
|
+
// @parity/product-sdk-signer's ConnectionStatus, which tracks a signer provider.
|
|
62
|
+
export { subscribeConnectionStatus } from "./transport.js";
|
|
63
|
+
export type { HostConnectionStatus } from "./transport.js";
|
|
64
|
+
|
|
60
65
|
// Result type + typed host errors (the throw→Result boundary)
|
|
61
66
|
export { ok, err } from "./result.js";
|
|
62
67
|
export type { Result } from "./result.js";
|
|
@@ -65,6 +70,7 @@ export {
|
|
|
65
70
|
HostError,
|
|
66
71
|
HostUnavailableError,
|
|
67
72
|
HostCallFailedError,
|
|
73
|
+
HostResponseDecodeError,
|
|
68
74
|
isHostError,
|
|
69
75
|
formatHostError,
|
|
70
76
|
} from "./errors.js";
|
|
@@ -74,6 +80,7 @@ export type { HostErrorPayload } from "./errors.js";
|
|
|
74
80
|
export { getAccountsProvider, findRingVrfKeyHandle } from "./accounts.js";
|
|
75
81
|
export type {
|
|
76
82
|
AccountsProvider,
|
|
83
|
+
WithDecodeError,
|
|
77
84
|
DerivationIndex,
|
|
78
85
|
HostAccount,
|
|
79
86
|
ProductAccount,
|
package/src/papi-provider.ts
CHANGED
|
@@ -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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
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(
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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
|
};
|