@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/index.d.ts CHANGED
@@ -55,25 +55,39 @@ export interface ProviderHealth {
55
55
  status: 'ok' | 'degraded' | 'offline';
56
56
  lastFailureAtTs?: number;
57
57
  }
58
- export interface ProviderConfig {
58
+ /** One configured provider identity. It is independent of every service. */
59
+ export interface ProviderInstanceConfig {
60
+ /** Stable key used by service-method attachments. */
61
+ instanceKey: string;
59
62
  providerId: string;
60
- /** Named relay, for providers configurable more than once. */
61
- instanceKey?: string;
62
- priority: number;
63
+ /** Disables every method on this instance without deleting its configuration. */
63
64
  enabled: boolean;
64
65
  config: Record<string, unknown>;
65
66
  /** Names a vault entry. NEVER the secret itself — a dump yields metadata. */
66
67
  secretRef: string;
68
+ }
69
+ /** One service method's attachment to one provider instance method. */
70
+ export interface ServiceMethodAttachment {
71
+ instanceKey: string;
72
+ /** Explicit provider capability; it is never inferred from the service name. */
73
+ providerMethod: string;
74
+ priority: number;
75
+ enabled: boolean;
67
76
  health?: ProviderHealth;
68
77
  }
69
- /** Where the ordered list lives: a database, a file, constants. */
78
+ /** Provider identities and service routing policy may live in any store. */
70
79
  export interface ConfigSource {
71
80
  readonly name: string;
72
- list(serviceKey: string): Promise<readonly ProviderConfig[]>;
81
+ provider(instanceKey: string): Promise<ProviderInstanceConfig | undefined>;
82
+ list(serviceKey: string, methodKey: string): Promise<readonly ServiceMethodAttachment[]>;
73
83
  /** Persisted, because strikes that reset on restart retry a dead provider forever. */
74
- recordHealth(serviceKey: string, providerId: string, health: ProviderHealth): Promise<void>;
84
+ recordHealth(serviceKey: string, methodKey: string, instanceKey: string, providerMethod: string, health: ProviderHealth): Promise<void>;
75
85
  }
76
- export declare function staticConfig(services: Record<string, readonly ProviderConfig[]>): ConfigSource;
86
+ export interface StaticRegistryConfig {
87
+ providers: Record<string, Omit<ProviderInstanceConfig, 'instanceKey'>>;
88
+ services: Record<string, Record<string, readonly ServiceMethodAttachment[]>>;
89
+ }
90
+ export declare function staticConfig(config: StaticRegistryConfig): ConfigSource;
77
91
  /**
78
92
  * How a failure should be treated.
79
93
  *
@@ -92,29 +106,58 @@ export interface InvokeContext {
92
106
  secret(field: string): Promise<string>;
93
107
  signal?: AbortSignal;
94
108
  }
95
- export interface ProviderSpec<Args = never, Result = never> {
109
+ export interface ProviderMethodSpec<Args = never, Result = never> {
110
+ invoke(context: InvokeContext, args: Args): Promise<Result>;
111
+ classify(error: unknown): FailureKind;
112
+ }
113
+ export declare function defineProviderMethod<Args, Result>(method: ProviderMethodSpec<Args, Result>): ProviderMethodSpec<Args, Result>;
114
+ export type AnyProviderMethod = ProviderMethodSpec<any, any>;
115
+ export type ProviderMethods = Record<string, AnyProviderMethod>;
116
+ /** A vendor/provider identity with named capabilities, independent of services. */
117
+ export interface ProviderDefinition<Methods extends ProviderMethods = ProviderMethods> {
96
118
  id: string;
97
- service: string;
98
119
  label: string;
99
120
  multiInstance?: boolean;
100
121
  /** JSON Schema. `writeOnly` fields route to secret storage. */
101
122
  credentials?: Record<string, unknown>;
102
123
  config?: Record<string, unknown>;
124
+ methods: Methods;
125
+ }
126
+ export declare function defineProvider<Methods extends ProviderMethods>(spec: ProviderDefinition<Methods>): ProviderDefinition<Methods>;
127
+ /** Convenience for a provider that exposes exactly one named method. */
128
+ export declare function defineSingleMethodProvider<Method extends string, Args, Result>(spec: Omit<ProviderDefinition<Record<Method, ProviderMethodSpec<Args, Result>>>, 'methods'> & {
129
+ method: Method;
103
130
  invoke(context: InvokeContext, args: Args): Promise<Result>;
104
131
  classify(error: unknown): FailureKind;
105
- }
106
- export declare function defineProvider<Args, Result>(spec: ProviderSpec<Args, Result>): ProviderSpec<Args, Result>;
132
+ }): ProviderDefinition<Record<Method, ProviderMethodSpec<Args, Result>>>;
107
133
  export declare const STRIKES_TO_OFFLINE = 3;
108
134
  export interface Attempt {
109
135
  providerId: string;
136
+ instanceKey: string;
137
+ providerMethod: string;
110
138
  outcome: 'sent' | 'skipped' | 'failed';
111
139
  kind?: FailureKind;
140
+ durationMs?: number;
141
+ failure?: {
142
+ kind: FailureKind;
143
+ code?: string;
144
+ message: string;
145
+ };
146
+ /** @deprecated Read `failure.message`; retained for 0.1 compatibility. */
112
147
  error?: string;
113
148
  }
114
149
  export interface CallResult<Result> {
115
150
  ok: boolean;
116
151
  result?: Result;
117
152
  provider?: string;
153
+ instance?: string;
154
+ method?: string;
155
+ selected?: {
156
+ providerId: string;
157
+ instanceKey: string;
158
+ providerMethod: string;
159
+ };
160
+ fallbackUsed: boolean;
118
161
  attempts: readonly Attempt[];
119
162
  error?: ProviderError;
120
163
  }
@@ -122,6 +165,20 @@ export interface CallOptions {
122
165
  /** Reaches provider fetch/socket code; cancellation never falls through to another vendor. */
123
166
  signal?: AbortSignal;
124
167
  }
168
+ /** Type-only input/output contract for one service method. */
169
+ export interface ServiceMethodContract<Args, Result> {
170
+ readonly __args?: (args: Args) => void;
171
+ readonly __result?: Result;
172
+ }
173
+ export declare function serviceMethod<Args, Result>(): ServiceMethodContract<Args, Result>;
174
+ export type ServiceMethods = Record<string, ServiceMethodContract<any, any>>;
175
+ export interface ServiceDefinition<Key extends string, Methods extends ServiceMethods> {
176
+ key: Key;
177
+ methods: Methods;
178
+ }
179
+ export declare function defineService<Key extends string, Methods extends ServiceMethods>(definition: ServiceDefinition<Key, Methods>): ServiceDefinition<Key, Methods>;
180
+ type ServiceArgs<T> = T extends ServiceMethodContract<infer Args, unknown> ? Args : never;
181
+ type ServiceResult<T> = T extends ServiceMethodContract<unknown, infer Result> ? Result : never;
125
182
  export interface RegistryOptions {
126
183
  credentials: CredentialSource;
127
184
  config: ConfigSource;
@@ -136,16 +193,21 @@ export interface RegistryOptions {
136
193
  * keep at the DEFINITION site, where `defineProvider` still checks that
137
194
  * `invoke` matches what the provider claims to take and return.
138
195
  */
139
- providers: readonly ProviderSpec<any, any>[];
196
+ providers: readonly ProviderDefinition[];
140
197
  before?: (context: {
141
198
  service: string;
199
+ method: string;
142
200
  provider: string;
143
- }) => void;
144
- after?: (result: CallResult<unknown>) => void;
201
+ instance: string;
202
+ }) => void | Promise<void>;
203
+ after?: (result: CallResult<unknown>) => void | Promise<void>;
145
204
  }
146
205
  export declare function nextHealth(current: ProviderHealth | undefined, kind: FailureKind | 'success'): ProviderHealth;
147
206
  export declare function createRegistry(options: RegistryOptions): {
148
- call: <Result>(serviceKey: string, args: unknown, callOptions?: CallOptions) => Promise<CallResult<Result>>;
207
+ call: <Result>(serviceKey: string, methodKey: string, args: unknown, callOptions?: CallOptions) => Promise<CallResult<Result>>;
208
+ service: <Key extends string, Methods extends ServiceMethods>(definition: ServiceDefinition<Key, Methods>) => {
209
+ call<Method extends Extract<keyof Methods, string>>(method: Method, args: ServiceArgs<Methods[Method]>, callOptions?: CallOptions): Promise<CallResult<ServiceResult<Methods[Method]>>>;
210
+ };
149
211
  };
150
212
  /** Passed to `registry.call('email', message)`. */
151
213
  export interface EmailMessage {
@@ -176,4 +238,5 @@ export interface EmailBatchResult {
176
238
  };
177
239
  results: readonly EmailBatchItemResult[];
178
240
  }
179
- export declare const VERSION = "0.1.9";
241
+ export declare const VERSION = "0.1.10";
242
+ export {};
package/dist/index.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,84 +91,125 @@ 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
  export {
149
206
  staticConfig,
207
+ serviceMethod,
150
208
  nextHealth,
151
209
  envCredentials,
210
+ defineSingleMethodProvider,
211
+ defineService,
212
+ defineProviderMethod,
152
213
  defineProvider,
153
214
  createRegistry,
154
215
  chainCredentials,
package/dist/pool.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: {
package/dist/storage.d.ts CHANGED
@@ -133,5 +133,5 @@ export type StorageRequest = {
133
133
  expiresInSec?: number;
134
134
  method?: 'GET' | 'PUT';
135
135
  };
136
- export declare const s3: import("./index").ProviderSpec<StorageRequest, unknown>;
137
- export declare const storageProviders: readonly [import("./index").ProviderSpec<StorageRequest, unknown>];
136
+ export declare const s3: import("./index").ProviderDefinition<Record<"request", import("./index").ProviderMethodSpec<StorageRequest, unknown>>>;
137
+ export declare const storageProviders: readonly [import("./index").ProviderDefinition<Record<"request", import("./index").ProviderMethodSpec<StorageRequest, unknown>>>];