@forgezero/providers 0.1.9 → 0.1.10

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/email.js CHANGED
@@ -38,25 +38,45 @@ function chainCredentials(...sources) {
38
38
  }
39
39
  };
40
40
  }
41
- function staticConfig(services) {
41
+ function staticConfig(config) {
42
42
  const health = new Map;
43
43
  return {
44
44
  name: "static",
45
- async list(serviceKey) {
46
- return (services[serviceKey] ?? []).map((provider) => ({
47
- ...provider,
48
- health: health.get(`${serviceKey}:${provider.providerId}`) ?? provider.health
45
+ async provider(instanceKey) {
46
+ const provider = config.providers[instanceKey];
47
+ return provider ? { instanceKey, ...provider, config: { ...provider.config } } : undefined;
48
+ },
49
+ async list(serviceKey, methodKey) {
50
+ return (config.services[serviceKey]?.[methodKey] ?? []).map((attachment) => ({
51
+ ...attachment,
52
+ health: health.get(`${serviceKey}:${methodKey}:${attachment.instanceKey}:${attachment.providerMethod}`) ?? attachment.health
49
53
  }));
50
54
  },
51
- async recordHealth(serviceKey, providerId, next) {
52
- health.set(`${serviceKey}:${providerId}`, next);
55
+ async recordHealth(serviceKey, methodKey, instanceKey, providerMethod, next) {
56
+ health.set(`${serviceKey}:${methodKey}:${instanceKey}:${providerMethod}`, next);
53
57
  }
54
58
  };
55
59
  }
60
+ function defineProviderMethod(method) {
61
+ return method;
62
+ }
56
63
  function defineProvider(spec) {
57
64
  return spec;
58
65
  }
66
+ function defineSingleMethodProvider(spec) {
67
+ const { method, invoke, classify, ...identity } = spec;
68
+ return defineProvider({
69
+ ...identity,
70
+ methods: { [method]: defineProviderMethod({ invoke, classify }) }
71
+ });
72
+ }
59
73
  var STRIKES_TO_OFFLINE = 3;
74
+ function serviceMethod() {
75
+ return Object.freeze({});
76
+ }
77
+ function defineService(definition) {
78
+ return definition;
79
+ }
60
80
  function nextHealth(current, kind) {
61
81
  if (kind === "success")
62
82
  return { strikes: 0, status: "ok" };
@@ -71,80 +91,117 @@ function nextHealth(current, kind) {
71
91
  }
72
92
  function createRegistry(options) {
73
93
  const byId = new Map(options.providers.map((provider) => [provider.id, provider]));
74
- async function call(serviceKey, args, callOptions = {}) {
75
- const configured = [...await options.config.list(serviceKey)].filter((provider) => provider.enabled).sort((a, b) => a.priority - b.priority);
94
+ async function call(serviceKey, methodKey, args, callOptions = {}) {
95
+ const configured = [...await options.config.list(serviceKey, methodKey)].filter((attachment) => attachment.enabled).sort((a, b) => a.priority - b.priority);
76
96
  const attempts = [];
77
- for (const entry of configured) {
97
+ for (const attachment of configured) {
78
98
  if (callOptions.signal?.aborted) {
79
99
  const cancelled = {
80
100
  ok: false,
101
+ fallbackUsed: attempts.length > 0,
81
102
  attempts,
82
- error: new ProviderError("CALL_ABORTED", `The "${serviceKey}" call was cancelled.`)
103
+ error: new ProviderError("CALL_ABORTED", `The "${serviceKey}.${methodKey}" call was cancelled.`)
83
104
  };
84
- options.after?.(cancelled);
105
+ await options.after?.(cancelled);
85
106
  return cancelled;
86
107
  }
87
- const spec = byId.get(entry.providerId);
88
- if (!spec) {
89
- attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "not registered" });
108
+ const instance = await options.config.provider(attachment.instanceKey);
109
+ if (!instance) {
110
+ attempts.push({ providerId: "unknown", instanceKey: attachment.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "instance not registered" });
111
+ continue;
112
+ }
113
+ const spec = byId.get(instance.providerId);
114
+ const method = spec?.methods[attachment.providerMethod];
115
+ if (!spec || !method) {
116
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: !spec ? "provider not registered" : "method not supported" });
90
117
  continue;
91
118
  }
92
- if (entry.health?.status === "offline") {
93
- attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "offline" });
119
+ if (!instance.enabled) {
120
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "instance disabled" });
94
121
  continue;
95
122
  }
96
- options.before?.({ service: serviceKey, provider: entry.providerId });
123
+ if (attachment.health?.status === "offline") {
124
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "offline" });
125
+ continue;
126
+ }
127
+ await options.before?.({ service: serviceKey, method: methodKey, provider: instance.providerId, instance: instance.instanceKey });
128
+ const startedAt = performance.now();
97
129
  try {
98
- const result = await spec.invoke({
99
- config: entry.config,
100
- secret: (field) => options.credentials.get(entry.secretRef, field),
130
+ const result = await method.invoke({
131
+ config: instance.config,
132
+ secret: (field) => options.credentials.get(instance.secretRef, field),
101
133
  signal: callOptions.signal
102
134
  }, args);
103
- attempts.push({ providerId: entry.providerId, outcome: "sent" });
104
- await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, "success"));
105
- const sent = { ok: true, result, provider: entry.providerId, attempts };
106
- options.after?.(sent);
135
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "sent", durationMs: performance.now() - startedAt });
136
+ await options.config.recordHealth(serviceKey, methodKey, instance.instanceKey, attachment.providerMethod, nextHealth(attachment.health, "success"));
137
+ const sent = {
138
+ ok: true,
139
+ result,
140
+ provider: instance.providerId,
141
+ instance: instance.instanceKey,
142
+ method: attachment.providerMethod,
143
+ selected: { providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod },
144
+ fallbackUsed: attempts.length > 1,
145
+ attempts
146
+ };
147
+ await options.after?.(sent);
107
148
  return sent;
108
149
  } catch (error) {
109
150
  if (callOptions.signal?.aborted) {
110
151
  const cancelled = {
111
152
  ok: false,
153
+ fallbackUsed: attempts.length > 0,
112
154
  attempts,
113
- error: new ProviderError("CALL_ABORTED", `The "${serviceKey}" call was cancelled.`)
155
+ error: new ProviderError("CALL_ABORTED", `The "${serviceKey}.${methodKey}" call was cancelled.`)
114
156
  };
115
- options.after?.(cancelled);
157
+ await options.after?.(cancelled);
116
158
  return cancelled;
117
159
  }
118
- const kind = spec.classify(error);
160
+ const kind = method.classify(error);
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ const code = error instanceof ProviderError ? error.code : typeof error?.code === "string" ? error.code : undefined;
119
163
  attempts.push({
120
- providerId: entry.providerId,
164
+ providerId: instance.providerId,
165
+ instanceKey: instance.instanceKey,
166
+ providerMethod: attachment.providerMethod,
121
167
  outcome: "failed",
122
168
  kind,
123
- error: error instanceof Error ? error.message : String(error)
169
+ durationMs: performance.now() - startedAt,
170
+ failure: { kind, ...code ? { code } : {}, message },
171
+ error: message
124
172
  });
125
- await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, kind));
173
+ await options.config.recordHealth(serviceKey, methodKey, instance.instanceKey, attachment.providerMethod, nextHealth(attachment.health, kind));
126
174
  if (kind === "terminal") {
127
175
  const refused = {
128
176
  ok: false,
177
+ fallbackUsed: attempts.length > 1,
129
178
  attempts,
130
179
  error: new ProviderError("PAYLOAD_REJECTED", "The request was refused as malformed; no provider will accept it.")
131
180
  };
132
- options.after?.(refused);
181
+ await options.after?.(refused);
133
182
  return refused;
134
183
  }
135
184
  }
136
185
  }
137
186
  const failed = {
138
187
  ok: false,
188
+ fallbackUsed: attempts.length > 1,
139
189
  attempts,
140
- error: new ProviderError(attempts.length === 0 ? "NO_PROVIDER" : "ALL_PROVIDERS_FAILED", attempts.length === 0 ? `No provider is configured for "${serviceKey}".` : `Every provider for "${serviceKey}" failed or was skipped.`)
190
+ error: new ProviderError(attempts.length === 0 ? "NO_PROVIDER" : "ALL_PROVIDERS_FAILED", attempts.length === 0 ? `No provider method is configured for "${serviceKey}.${methodKey}".` : `Every provider method for "${serviceKey}.${methodKey}" failed or was skipped.`)
141
191
  };
142
- options.after?.(failed);
192
+ await options.after?.(failed);
143
193
  return failed;
144
194
  }
145
- return { call };
195
+ function service(definition) {
196
+ return {
197
+ call(method, args, callOptions) {
198
+ return call(definition.key, method, args, callOptions);
199
+ }
200
+ };
201
+ }
202
+ return { call, service };
146
203
  }
147
- var VERSION = "0.1.9";
204
+ var VERSION = "0.1.10";
148
205
 
149
206
  // src/email.ts
150
207
  var recipients = (to) => Array.isArray(to) ? [...to] : [to];
@@ -156,31 +213,28 @@ function assertMessage(message) {
156
213
  throw new ProviderError("EMAIL_NO_BODY", "An email needs html, text, or both.");
157
214
  }
158
215
  }
159
- var jetemail = defineProvider({
160
- id: "jetemail",
161
- service: "email",
162
- label: "JetEmail",
163
- credentials: {
164
- type: "object",
165
- additionalProperties: false,
166
- required: ["apiKey"],
167
- properties: {
168
- apiKey: {
169
- type: "string",
170
- title: "API key",
171
- writeOnly: true,
172
- description: "Transactional key from the JetEmail dashboard."
173
- }
216
+ var jetemailCredentials = {
217
+ type: "object",
218
+ additionalProperties: false,
219
+ required: ["apiKey"],
220
+ properties: {
221
+ apiKey: {
222
+ type: "string",
223
+ title: "API key",
224
+ writeOnly: true,
225
+ description: "Transactional key from the JetEmail dashboard."
174
226
  }
175
- },
176
- config: {
177
- type: "object",
178
- additionalProperties: false,
179
- properties: {
180
- eu: { type: "boolean", default: false, title: "EU residency" },
181
- from: { type: "string", format: "email", title: "Default from address" }
182
- }
183
- },
227
+ }
228
+ };
229
+ var jetemailConfig = {
230
+ type: "object",
231
+ additionalProperties: false,
232
+ properties: {
233
+ eu: { type: "boolean", default: false, title: "EU residency" },
234
+ from: { type: "string", format: "email", title: "Default from address" }
235
+ }
236
+ };
237
+ var jetemailSend = defineProviderMethod({
184
238
  async invoke(context, message) {
185
239
  assertMessage(message);
186
240
  const config = context.config;
@@ -230,12 +284,7 @@ function assertBatch(batch) {
230
284
  for (const message of batch.emails)
231
285
  assertMessage(message);
232
286
  }
233
- var jetemailBatch = defineProvider({
234
- id: "jetemail",
235
- service: "email.batch",
236
- label: "JetEmail",
237
- credentials: jetemail.credentials,
238
- config: jetemail.config,
287
+ var jetemailSendBatch = defineProviderMethod({
239
288
  async invoke(context, batch) {
240
289
  assertBatch(batch);
241
290
  const config = context.config;
@@ -287,37 +336,19 @@ var jetemailBatch = defineProvider({
287
336
  });
288
337
  return { summary, results };
289
338
  },
290
- classify: jetemail.classify
339
+ classify: jetemailSend.classify
291
340
  });
292
- var smtp = defineProvider({
293
- id: "smtp",
294
- service: "email",
295
- label: "SMTP",
296
- multiInstance: true,
297
- credentials: {
298
- type: "object",
299
- additionalProperties: false,
300
- properties: {
301
- user: { type: "string", title: "Username" },
302
- password: { type: "string", title: "Password", writeOnly: true }
303
- }
304
- },
305
- config: {
306
- type: "object",
307
- additionalProperties: false,
308
- required: ["host", "from"],
309
- properties: {
310
- host: { type: "string", title: "Host" },
311
- port: { type: "integer", default: 587, minimum: 1, maximum: 65535 },
312
- secure: {
313
- type: "boolean",
314
- default: false,
315
- title: "Implicit TLS",
316
- description: "True for port 465. Port 587 upgrades with STARTTLS and should stay false."
317
- },
318
- from: { type: "string", format: "email", title: "From address" }
319
- }
320
- },
341
+ var jetemail = defineProvider({
342
+ id: "jetemail",
343
+ label: "JetEmail",
344
+ credentials: jetemailCredentials,
345
+ config: jetemailConfig,
346
+ methods: {
347
+ send: jetemailSend,
348
+ sendBatch: jetemailSendBatch
349
+ }
350
+ });
351
+ var smtpSend = defineProviderMethod({
321
352
  async invoke(context, message) {
322
353
  assertMessage(message);
323
354
  const config = context.config;
@@ -356,45 +387,47 @@ var smtp = defineProvider({
356
387
  return "retryable";
357
388
  }
358
389
  });
359
- var smtpBatch = defineProvider({
390
+ var smtp = defineProvider({
360
391
  id: "smtp",
361
- service: "email.batch",
362
392
  label: "SMTP",
363
393
  multiInstance: true,
364
- credentials: smtp.credentials,
365
- config: smtp.config,
366
- async invoke(context, batch) {
367
- assertBatch(batch);
368
- const results = [];
369
- for (let offset = 0;offset < batch.emails.length; offset += 5) {
370
- const rows = await Promise.all(batch.emails.slice(offset, offset + 5).map(async (message) => {
371
- try {
372
- const sent = await smtp.invoke(context, message);
373
- return { status: "success", id: sent.id };
374
- } catch (cause) {
375
- return {
376
- status: "failed",
377
- error: cause instanceof Error ? cause.message.slice(0, 500) : "SMTP send failed"
378
- };
379
- }
380
- }));
381
- results.push(...rows);
394
+ credentials: {
395
+ type: "object",
396
+ additionalProperties: false,
397
+ properties: {
398
+ user: { type: "string", title: "Username" },
399
+ password: { type: "string", title: "Password", writeOnly: true }
382
400
  }
383
- const successful = results.filter((result) => result.status === "success").length;
384
- return {
385
- summary: { total: results.length, successful, failed: results.length - successful },
386
- results
387
- };
388
401
  },
389
- classify: smtp.classify
402
+ config: {
403
+ type: "object",
404
+ additionalProperties: false,
405
+ required: ["host", "from"],
406
+ properties: {
407
+ host: { type: "string", title: "Host" },
408
+ port: { type: "integer", default: 587, minimum: 1, maximum: 65535 },
409
+ secure: {
410
+ type: "boolean",
411
+ default: false,
412
+ title: "Implicit TLS",
413
+ description: "True for port 465. Port 587 upgrades with STARTTLS and should stay false."
414
+ },
415
+ from: { type: "string", format: "email", title: "From address" }
416
+ }
417
+ },
418
+ methods: { send: smtpSend }
390
419
  });
391
420
  var emailProviders = [jetemail, smtp];
392
- var emailBatchProviders = [jetemailBatch, smtpBatch];
421
+ var emailService = defineService({
422
+ key: "email",
423
+ methods: {
424
+ send: serviceMethod(),
425
+ sendBatch: serviceMethod()
426
+ }
427
+ });
393
428
  export {
394
- smtpBatch,
395
429
  smtp,
396
- jetemailBatch,
397
430
  jetemail,
398
- emailProviders,
399
- emailBatchProviders
431
+ emailService,
432
+ emailProviders
400
433
  };
package/dist/http.d.ts CHANGED
@@ -93,8 +93,8 @@ export type HttpClient = ReturnType<typeof createHttpClient>;
93
93
  * `classify` is where the venue-specific knowledge lives, and it is the part
94
94
  * that decides whether a retry helps or makes things worse.
95
95
  */
96
- export declare const http: import("./index").ProviderSpec<HttpRequest, HttpResponse<unknown>>;
96
+ export declare const http: import("./index").ProviderDefinition<Record<"request", import("./index").ProviderMethodSpec<HttpRequest, HttpResponse<unknown>>>>;
97
97
  /** Binance reports a running total for the window, not a per-call cost. */
98
98
  export declare const binanceWeight: (response: Response) => number | undefined;
99
- export declare const httpProviders: readonly [import("./index").ProviderSpec<HttpRequest, HttpResponse<unknown>>];
99
+ export declare const httpProviders: readonly [import("./index").ProviderDefinition<Record<"request", import("./index").ProviderMethodSpec<HttpRequest, HttpResponse<unknown>>>>];
100
100
  export {};
package/dist/http.js CHANGED
@@ -38,25 +38,45 @@ function chainCredentials(...sources) {
38
38
  }
39
39
  };
40
40
  }
41
- function staticConfig(services) {
41
+ function staticConfig(config) {
42
42
  const health = new Map;
43
43
  return {
44
44
  name: "static",
45
- async list(serviceKey) {
46
- return (services[serviceKey] ?? []).map((provider) => ({
47
- ...provider,
48
- health: health.get(`${serviceKey}:${provider.providerId}`) ?? provider.health
45
+ async provider(instanceKey) {
46
+ const provider = config.providers[instanceKey];
47
+ return provider ? { instanceKey, ...provider, config: { ...provider.config } } : undefined;
48
+ },
49
+ async list(serviceKey, methodKey) {
50
+ return (config.services[serviceKey]?.[methodKey] ?? []).map((attachment) => ({
51
+ ...attachment,
52
+ health: health.get(`${serviceKey}:${methodKey}:${attachment.instanceKey}:${attachment.providerMethod}`) ?? attachment.health
49
53
  }));
50
54
  },
51
- async recordHealth(serviceKey, providerId, next) {
52
- health.set(`${serviceKey}:${providerId}`, next);
55
+ async recordHealth(serviceKey, methodKey, instanceKey, providerMethod, next) {
56
+ health.set(`${serviceKey}:${methodKey}:${instanceKey}:${providerMethod}`, next);
53
57
  }
54
58
  };
55
59
  }
60
+ function defineProviderMethod(method) {
61
+ return method;
62
+ }
56
63
  function defineProvider(spec) {
57
64
  return spec;
58
65
  }
66
+ function defineSingleMethodProvider(spec) {
67
+ const { method, invoke, classify, ...identity } = spec;
68
+ return defineProvider({
69
+ ...identity,
70
+ methods: { [method]: defineProviderMethod({ invoke, classify }) }
71
+ });
72
+ }
59
73
  var STRIKES_TO_OFFLINE = 3;
74
+ function serviceMethod() {
75
+ return Object.freeze({});
76
+ }
77
+ function defineService(definition) {
78
+ return definition;
79
+ }
60
80
  function nextHealth(current, kind) {
61
81
  if (kind === "success")
62
82
  return { strikes: 0, status: "ok" };
@@ -71,80 +91,117 @@ function nextHealth(current, kind) {
71
91
  }
72
92
  function createRegistry(options) {
73
93
  const byId = new Map(options.providers.map((provider) => [provider.id, provider]));
74
- async function call(serviceKey, args, callOptions = {}) {
75
- const configured = [...await options.config.list(serviceKey)].filter((provider) => provider.enabled).sort((a, b) => a.priority - b.priority);
94
+ async function call(serviceKey, methodKey, args, callOptions = {}) {
95
+ const configured = [...await options.config.list(serviceKey, methodKey)].filter((attachment) => attachment.enabled).sort((a, b) => a.priority - b.priority);
76
96
  const attempts = [];
77
- for (const entry of configured) {
97
+ for (const attachment of configured) {
78
98
  if (callOptions.signal?.aborted) {
79
99
  const cancelled = {
80
100
  ok: false,
101
+ fallbackUsed: attempts.length > 0,
81
102
  attempts,
82
- error: new ProviderError("CALL_ABORTED", `The "${serviceKey}" call was cancelled.`)
103
+ error: new ProviderError("CALL_ABORTED", `The "${serviceKey}.${methodKey}" call was cancelled.`)
83
104
  };
84
- options.after?.(cancelled);
105
+ await options.after?.(cancelled);
85
106
  return cancelled;
86
107
  }
87
- const spec = byId.get(entry.providerId);
88
- if (!spec) {
89
- attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "not registered" });
108
+ const instance = await options.config.provider(attachment.instanceKey);
109
+ if (!instance) {
110
+ attempts.push({ providerId: "unknown", instanceKey: attachment.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "instance not registered" });
111
+ continue;
112
+ }
113
+ const spec = byId.get(instance.providerId);
114
+ const method = spec?.methods[attachment.providerMethod];
115
+ if (!spec || !method) {
116
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: !spec ? "provider not registered" : "method not supported" });
90
117
  continue;
91
118
  }
92
- if (entry.health?.status === "offline") {
93
- attempts.push({ providerId: entry.providerId, outcome: "skipped", error: "offline" });
119
+ if (!instance.enabled) {
120
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "instance disabled" });
94
121
  continue;
95
122
  }
96
- options.before?.({ service: serviceKey, provider: entry.providerId });
123
+ if (attachment.health?.status === "offline") {
124
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "skipped", error: "offline" });
125
+ continue;
126
+ }
127
+ await options.before?.({ service: serviceKey, method: methodKey, provider: instance.providerId, instance: instance.instanceKey });
128
+ const startedAt = performance.now();
97
129
  try {
98
- const result = await spec.invoke({
99
- config: entry.config,
100
- secret: (field) => options.credentials.get(entry.secretRef, field),
130
+ const result = await method.invoke({
131
+ config: instance.config,
132
+ secret: (field) => options.credentials.get(instance.secretRef, field),
101
133
  signal: callOptions.signal
102
134
  }, args);
103
- attempts.push({ providerId: entry.providerId, outcome: "sent" });
104
- await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, "success"));
105
- const sent = { ok: true, result, provider: entry.providerId, attempts };
106
- options.after?.(sent);
135
+ attempts.push({ providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod, outcome: "sent", durationMs: performance.now() - startedAt });
136
+ await options.config.recordHealth(serviceKey, methodKey, instance.instanceKey, attachment.providerMethod, nextHealth(attachment.health, "success"));
137
+ const sent = {
138
+ ok: true,
139
+ result,
140
+ provider: instance.providerId,
141
+ instance: instance.instanceKey,
142
+ method: attachment.providerMethod,
143
+ selected: { providerId: instance.providerId, instanceKey: instance.instanceKey, providerMethod: attachment.providerMethod },
144
+ fallbackUsed: attempts.length > 1,
145
+ attempts
146
+ };
147
+ await options.after?.(sent);
107
148
  return sent;
108
149
  } catch (error) {
109
150
  if (callOptions.signal?.aborted) {
110
151
  const cancelled = {
111
152
  ok: false,
153
+ fallbackUsed: attempts.length > 0,
112
154
  attempts,
113
- error: new ProviderError("CALL_ABORTED", `The "${serviceKey}" call was cancelled.`)
155
+ error: new ProviderError("CALL_ABORTED", `The "${serviceKey}.${methodKey}" call was cancelled.`)
114
156
  };
115
- options.after?.(cancelled);
157
+ await options.after?.(cancelled);
116
158
  return cancelled;
117
159
  }
118
- const kind = spec.classify(error);
160
+ const kind = method.classify(error);
161
+ const message = error instanceof Error ? error.message : String(error);
162
+ const code = error instanceof ProviderError ? error.code : typeof error?.code === "string" ? error.code : undefined;
119
163
  attempts.push({
120
- providerId: entry.providerId,
164
+ providerId: instance.providerId,
165
+ instanceKey: instance.instanceKey,
166
+ providerMethod: attachment.providerMethod,
121
167
  outcome: "failed",
122
168
  kind,
123
- error: error instanceof Error ? error.message : String(error)
169
+ durationMs: performance.now() - startedAt,
170
+ failure: { kind, ...code ? { code } : {}, message },
171
+ error: message
124
172
  });
125
- await options.config.recordHealth(serviceKey, entry.providerId, nextHealth(entry.health, kind));
173
+ await options.config.recordHealth(serviceKey, methodKey, instance.instanceKey, attachment.providerMethod, nextHealth(attachment.health, kind));
126
174
  if (kind === "terminal") {
127
175
  const refused = {
128
176
  ok: false,
177
+ fallbackUsed: attempts.length > 1,
129
178
  attempts,
130
179
  error: new ProviderError("PAYLOAD_REJECTED", "The request was refused as malformed; no provider will accept it.")
131
180
  };
132
- options.after?.(refused);
181
+ await options.after?.(refused);
133
182
  return refused;
134
183
  }
135
184
  }
136
185
  }
137
186
  const failed = {
138
187
  ok: false,
188
+ fallbackUsed: attempts.length > 1,
139
189
  attempts,
140
- error: new ProviderError(attempts.length === 0 ? "NO_PROVIDER" : "ALL_PROVIDERS_FAILED", attempts.length === 0 ? `No provider is configured for "${serviceKey}".` : `Every provider for "${serviceKey}" failed or was skipped.`)
190
+ error: new ProviderError(attempts.length === 0 ? "NO_PROVIDER" : "ALL_PROVIDERS_FAILED", attempts.length === 0 ? `No provider method is configured for "${serviceKey}.${methodKey}".` : `Every provider method for "${serviceKey}.${methodKey}" failed or was skipped.`)
141
191
  };
142
- options.after?.(failed);
192
+ await options.after?.(failed);
143
193
  return failed;
144
194
  }
145
- return { call };
195
+ function service(definition) {
196
+ return {
197
+ call(method, args, callOptions) {
198
+ return call(definition.key, method, args, callOptions);
199
+ }
200
+ };
201
+ }
202
+ return { call, service };
146
203
  }
147
- var VERSION = "0.1.9";
204
+ var VERSION = "0.1.10";
148
205
 
149
206
  // src/http.ts
150
207
  class BudgetExhausted extends ProviderError {
@@ -227,9 +284,9 @@ function createHttpClient(config) {
227
284
  budget: () => budgetState(config.budget.host)
228
285
  };
229
286
  }
230
- var http = defineProvider({
287
+ var http = defineSingleMethodProvider({
231
288
  id: "http",
232
- service: "http",
289
+ method: "request",
233
290
  label: "HTTP",
234
291
  multiInstance: true,
235
292
  credentials: {