@layerall/core 0.1.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/README.md ADDED
@@ -0,0 +1,53 @@
1
+ # @layerall/core
2
+
3
+ The orchestration engine for LayerAll: routing strategies, provider router with retries/fallback and typed policies.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @layerall/core
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```ts
14
+ import { Router, type PolicyDocument, type Provider } from '@layerall/core';
15
+
16
+ const providers: Record<string, Provider> = {
17
+ providerA: {
18
+ id: 'providerA', weight: 50, health: 0.96, baseLatency: 180, failRate: 0.06,
19
+ invoke: async ctx => upstreamCallA(ctx),
20
+ },
21
+ providerB: { id: 'providerB', /* ... */ invoke: async ctx => upstreamCallB(ctx) },
22
+ };
23
+
24
+ const policy: PolicyDocument = {
25
+ tenants: {
26
+ default: {
27
+ providers: ['providerA', 'providerB'],
28
+ operations: {
29
+ create: { strategy: 'round_robin', timeoutMs: 8000, retries: { max: 1, backoffMs: 300 }, failover: true },
30
+ },
31
+ },
32
+ },
33
+ };
34
+
35
+ const router = new Router({
36
+ policy,
37
+ providers,
38
+ observer: { onAttempt: l => console.log(l), onFinish: r => console.log(r) },
39
+ });
40
+
41
+ const res = await router.execute('create', { externalId: 'req_123', data: {} });
42
+ ```
43
+
44
+ ## Strategies
45
+
46
+ | Strategy | Behaviour |
47
+ | -------------- | -------------------------------------------------- |
48
+ | `round_robin` | Cycles through eligible providers in policy order. |
49
+ | `load_balance` | Weighted random by `Provider.weight` / policy weights. |
50
+ | `most_fast` | Lowest score = `baseLatency + (1-health)*280 + failRate*420`. |
51
+ | `failover` | Tries eligible providers in order until one succeeds. |
52
+
53
+ See the root [README](../../README.md) for the product overview.
@@ -0,0 +1,185 @@
1
+ /** Canonical operations an orchestrator exposes to clients. */
2
+ type OperationName = 'create' | 'send' | 'status' | 'cancel';
3
+ /** Pluggable routing strategies. */
4
+ type StrategyName = 'round_robin' | 'load_balance' | 'most_fast' | 'failover';
5
+ /** A registered provider implementation. Adapters map domain calls to `invoke`. */
6
+ interface Provider<TContext = unknown, TResult = unknown> {
7
+ id: string;
8
+ /** Optional weights used by `load_balance`. Higher weight = more traffic share. */
9
+ weight?: number;
10
+ /** Optional capacity hint used by `load_balance` when `weight` is absent. */
11
+ capacity?: number;
12
+ /** Whether the provider is currently eligible for routing (defaults to true). */
13
+ enabled?: boolean;
14
+ /** Optional current health score in [0, 1]; used by `most_fast` and failover scoring. */
15
+ health?: number;
16
+ /** Optional base latency in ms; used by `most_fast` as the expected response time. */
17
+ baseLatency?: number;
18
+ /** Optional transient failure rate in [0, 1]; used by `most_fast` as a penalty. */
19
+ failRate?: number;
20
+ /**
21
+ * Executes the operation against this provider. Implementations translate the
22
+ * provider-agnostic call into a concrete downstream API request.
23
+ */
24
+ invoke: (ctx: InvokeContext<TContext>) => Promise<TResult>;
25
+ }
26
+ /** Context passed to a provider on invocation. `payload` is domain-specific. */
27
+ interface InvokeContext<TData = unknown> {
28
+ operation: OperationName;
29
+ requestId: string;
30
+ payload: OperationPayload<TData>;
31
+ signal?: AbortSignal;
32
+ }
33
+ /** Generic operation payload. `externalId` enables idempotency. */
34
+ interface OperationPayload<TData = unknown> {
35
+ externalId?: string;
36
+ data: TData;
37
+ }
38
+ /** Outcome returned to the client, normalized across providers. */
39
+ interface OperationResult<TResult = unknown> {
40
+ id: string;
41
+ requestId: string;
42
+ provider: string;
43
+ operation: OperationName;
44
+ status: 'succeeded' | 'failed';
45
+ result?: TResult;
46
+ error?: OperationError;
47
+ /** Latency observed for the successful/terminal call, in ms. */
48
+ latencyMs: number;
49
+ /** Number of provider attempts consumed (including the successful one). */
50
+ attempts: number;
51
+ /** Stable receipt for audit/storage; derived from provider + requestId. */
52
+ providerReceipt: string;
53
+ }
54
+ /** Normalized error surface. */
55
+ interface OperationError {
56
+ code: string;
57
+ message: string;
58
+ /** Whether the error is likely transient (retryable). */
59
+ transient?: boolean;
60
+ provider: string;
61
+ }
62
+ /** Per-operation retry policy. */
63
+ interface RetryPolicy {
64
+ max: number;
65
+ backoffMs: number;
66
+ /** Optional multiplier applied between successive backoffs (default 1, i.e. fixed). */
67
+ backoffMultiplier?: number;
68
+ }
69
+ /** Per-operation configuration entry in a tenant policy. */
70
+ interface OperationPolicy {
71
+ strategy?: StrategyName;
72
+ timeoutMs?: number;
73
+ retries?: RetryPolicy;
74
+ failover?: boolean;
75
+ /** Explicit weights keyed by provider id (overrides `Provider.weight`). */
76
+ weights?: Record<string, number>;
77
+ /** Optional short cache TTL for idempotent reads such as `status`. */
78
+ cacheTtlMs?: number;
79
+ }
80
+ /** Flags set by the client on a per-request basis to override policy. */
81
+ interface OperationRequestOptions {
82
+ strategy?: StrategyName;
83
+ timeoutMs?: number;
84
+ failover?: boolean;
85
+ signal?: AbortSignal;
86
+ }
87
+ /** A tenant routing policy. */
88
+ interface TenantPolicy {
89
+ providers: string[];
90
+ operations: Partial<Record<OperationName, OperationPolicy>>;
91
+ }
92
+ /** Root policy document keyed by tenant id. */
93
+ interface PolicyDocument {
94
+ tenants: Record<string, TenantPolicy>;
95
+ }
96
+ /** Metadata collected per attempt for observability. */
97
+ interface AttemptLog {
98
+ /** Id of the request this attempt belongs to. */
99
+ requestId: string;
100
+ /** Operation being executed. */
101
+ operation: OperationName;
102
+ provider: string;
103
+ attempt: number;
104
+ ok: boolean;
105
+ latencyMs: number;
106
+ transient: boolean;
107
+ /** Human-readable error message when `ok === false`. */
108
+ error?: string;
109
+ /** Stable error code (e.g. `upstream_error`, `timeout`) when `ok === false`. */
110
+ errorCode?: string;
111
+ }
112
+ /** Sink receiving attempt logs and final outcomes for observability. */
113
+ interface Observer {
114
+ onStart?(ev: {
115
+ requestId: string;
116
+ operation: OperationName;
117
+ strategy: StrategyName;
118
+ }): void;
119
+ onAttempt?(log: AttemptLog): void;
120
+ onFinish?(res: OperationResult): void;
121
+ }
122
+
123
+ /**
124
+ * Internal selection context shared by every strategy.
125
+ * Strategies MUST be pure given this state snapshot so they are trivially testable.
126
+ */
127
+ interface SelectionContext {
128
+ strategy: StrategyName;
129
+ /** Eligible providers, in policy order. */
130
+ eligible: Provider[];
131
+ /** Provider id weights across all registered providers (policy or provider-weighted). */
132
+ weights: Record<string, number>;
133
+ roundRobinIndex: {
134
+ value: number;
135
+ };
136
+ }
137
+ type Strategy = (ctx: SelectionContext) => Provider | null;
138
+ /** Round‑robin: cycles through eligible providers in order. */
139
+ declare const roundRobin: Strategy;
140
+ /** Weighted random selection by provider weight (capacity-aware when no weight). */
141
+ declare const loadBalance: Strategy;
142
+ /** Picks the provider with the lowest expected score = latency + health/failure penalty. */
143
+ declare const mostFast: Strategy;
144
+ /** Failover: returns the first eligible provider (the router tries them in order on failure). */
145
+ declare const failover: Strategy;
146
+ declare const strategies: Record<StrategyName, Strategy>;
147
+
148
+ interface RouterOptions {
149
+ policy: PolicyDocument;
150
+ providers: Record<string, Provider>;
151
+ tenant?: string;
152
+ observer?: Observer;
153
+ defaultStrategy?: StrategyName;
154
+ defaultTimeoutMs?: number;
155
+ }
156
+ declare class Router {
157
+ private readonly providers;
158
+ private readonly policy;
159
+ private readonly observer?;
160
+ private readonly defaultStrategy;
161
+ private readonly defaultTimeoutMs;
162
+ private readonly rrIndex;
163
+ constructor(opts: RouterOptions);
164
+ execute<TData = unknown, TResult = unknown>(operation: OperationName, payload: OperationPayload<TData>, options?: OperationRequestOptions): Promise<OperationResult<TResult>>;
165
+ private eligibleProviders;
166
+ private resolveTenant;
167
+ private signal;
168
+ private success;
169
+ private fail;
170
+ private emitAttempt;
171
+ }
172
+
173
+ /** Generates a short unique id (timestamp + random). */
174
+ declare function uid(prefix?: string): string;
175
+ /** Clamps `n` between `min` and `max`. */
176
+ declare function clamp(n: number, min: number, max: number): number;
177
+ /** Sleep helper that respects an optional AbortSignal. */
178
+ declare function sleep(ms: number, signal?: AbortSignal): Promise<void>;
179
+ declare class AbortedError extends Error {
180
+ constructor();
181
+ }
182
+ /** Builds a stable audit receipt from the provider id and requestId. */
183
+ declare function buildReceipt(providerId: string, requestId: string): string;
184
+
185
+ export { AbortedError, type AttemptLog, type InvokeContext, type Observer, type OperationError, type OperationName, type OperationPayload, type OperationPolicy, type OperationRequestOptions, type OperationResult, type PolicyDocument, type Provider, type RetryPolicy, Router, type RouterOptions, type SelectionContext, type Strategy, type StrategyName, type TenantPolicy, buildReceipt, clamp, failover, loadBalance, mostFast, roundRobin, sleep, strategies, uid };
package/dist/index.js ADDED
@@ -0,0 +1,266 @@
1
+ // src/strategies.ts
2
+ var roundRobin = (ctx) => {
3
+ const { eligible, roundRobinIndex } = ctx;
4
+ if (eligible.length === 0) return null;
5
+ const chosen = eligible[roundRobinIndex.value % eligible.length];
6
+ roundRobinIndex.value = (roundRobinIndex.value + 1) % Number.MAX_SAFE_INTEGER;
7
+ return chosen;
8
+ };
9
+ var loadBalance = (ctx) => {
10
+ const { eligible, weights } = ctx;
11
+ if (eligible.length === 0) return null;
12
+ const total = eligible.reduce(
13
+ (sum, p) => sum + (resolveWeight(p, weights) || 0),
14
+ 0
15
+ );
16
+ if (total <= 0) return eligible[0];
17
+ let r = Math.random() * total;
18
+ for (const p of eligible) {
19
+ r -= resolveWeight(p, weights) || 0;
20
+ if (r <= 0) return p;
21
+ }
22
+ return eligible[eligible.length - 1];
23
+ };
24
+ var mostFast = (ctx) => {
25
+ const { eligible } = ctx;
26
+ if (eligible.length === 0) return null;
27
+ let best = null;
28
+ let bestScore = Infinity;
29
+ for (const p of eligible) {
30
+ const noise = Math.random() * 28 - 14;
31
+ const latency = (p.baseLatency ?? 200) + noise;
32
+ const penalty = (1 - (p.health ?? 1)) * 280 + (p.failRate ?? 0) * 420;
33
+ const score = latency + penalty;
34
+ if (score < bestScore) {
35
+ bestScore = score;
36
+ best = p;
37
+ }
38
+ }
39
+ return best;
40
+ };
41
+ var failover = (ctx) => {
42
+ const { eligible } = ctx;
43
+ return eligible[0] ?? null;
44
+ };
45
+ var strategies = {
46
+ round_robin: roundRobin,
47
+ load_balance: loadBalance,
48
+ most_fast: mostFast,
49
+ failover
50
+ };
51
+ function resolveWeight(p, weights) {
52
+ const explicit = weights[p.id];
53
+ if (typeof explicit === "number") return explicit;
54
+ if (typeof p.weight === "number") return p.weight;
55
+ if (typeof p.capacity === "number") return p.capacity;
56
+ return 1;
57
+ }
58
+
59
+ // src/utils.ts
60
+ function uid(prefix = "") {
61
+ const ts = Date.now().toString(36);
62
+ const rand = Math.random().toString(36).slice(2, 10);
63
+ return `${prefix}${prefix ? "_" : ""}${ts}${rand}`;
64
+ }
65
+ function clamp(n, min, max) {
66
+ return Math.max(min, Math.min(max, n));
67
+ }
68
+ function sleep(ms, signal) {
69
+ return new Promise((resolve, reject) => {
70
+ if (signal?.aborted) return reject(new AbortedError());
71
+ const t = setTimeout(() => {
72
+ signal?.removeEventListener("abort", onAbort);
73
+ resolve();
74
+ }, Math.max(0, ms));
75
+ const onAbort = () => {
76
+ clearTimeout(t);
77
+ reject(new AbortedError());
78
+ };
79
+ signal?.addEventListener("abort", onAbort, { once: true });
80
+ });
81
+ }
82
+ var AbortedError = class extends Error {
83
+ constructor() {
84
+ super("aborted");
85
+ this.name = "AbortedError";
86
+ }
87
+ };
88
+ function buildReceipt(providerId, requestId) {
89
+ const h = simpleHash(`${providerId}:${requestId}`);
90
+ return `${providerId}:${requestId}:${h.toString(36)}`;
91
+ }
92
+ function simpleHash(input) {
93
+ let h = 2166136261;
94
+ for (let i = 0; i < input.length; i++) {
95
+ h ^= input.charCodeAt(i);
96
+ h = Math.imul(h, 16777619);
97
+ }
98
+ return h >>> 0;
99
+ }
100
+
101
+ // src/router.ts
102
+ var DEFAULT_TENANT = "default";
103
+ var Router = class {
104
+ constructor(opts) {
105
+ this.rrIndex = { value: 0 };
106
+ this.providers = opts.providers;
107
+ this.policy = opts.policy;
108
+ this.observer = opts.observer;
109
+ this.defaultStrategy = opts.defaultStrategy ?? "round_robin";
110
+ this.defaultTimeoutMs = opts.defaultTimeoutMs ?? 8e3;
111
+ }
112
+ async execute(operation, payload, options = {}) {
113
+ const tenant = this.resolveTenant();
114
+ const opPolicy = tenant.operations[operation] ?? {};
115
+ const strategy = options.strategy ?? opPolicy.strategy ?? this.defaultStrategy;
116
+ const timeoutMs = options.timeoutMs ?? opPolicy.timeoutMs ?? this.defaultTimeoutMs;
117
+ const failover2 = options.failover ?? opPolicy.failover ?? strategy === "failover";
118
+ const retries = opPolicy.retries ?? { max: 0, backoffMs: 300 };
119
+ const requestId = payload.externalId ?? uid("req");
120
+ this.observer?.onStart?.({ requestId, operation, strategy });
121
+ const eligible = this.eligibleProviders(tenant);
122
+ if (eligible.length === 0) {
123
+ return this.fail(requestId, operation, "no_providers", "Nenhum provedor ativo.", 0, 0);
124
+ }
125
+ const weights = opPolicy.weights ?? {};
126
+ const selectionCtx = {
127
+ strategy,
128
+ eligible,
129
+ weights,
130
+ roundRobinIndex: this.rrIndex
131
+ };
132
+ const startedAt = performance.now();
133
+ const order = failover2 ? eligible : [strategies[strategy](selectionCtx)].filter(
134
+ (p) => p !== null
135
+ );
136
+ const targets = order.length > 0 ? order : eligible;
137
+ let attempts = 0;
138
+ let lastError = null;
139
+ for (const provider of targets) {
140
+ const maxAttempts = 1 + (failover2 ? 0 : clamp(retries.max, 0, 5));
141
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
142
+ attempts++;
143
+ const attemptStart = performance.now();
144
+ try {
145
+ const signal = this.signal(timeoutMs, options.signal);
146
+ const result = await provider.invoke({
147
+ operation,
148
+ requestId,
149
+ payload,
150
+ signal
151
+ });
152
+ const latencyMs = Math.round(performance.now() - attemptStart);
153
+ this.emitAttempt(provider.id, attempt, true, latencyMs, false, void 0, void 0, requestId, operation);
154
+ const res = this.success(requestId, provider.id, operation, result, latencyMs, attempts);
155
+ this.observer?.onFinish?.(res);
156
+ return res;
157
+ } catch (err) {
158
+ const latencyMs = Math.round(performance.now() - attemptStart);
159
+ const transient = isTransient(err);
160
+ this.emitAttempt(provider.id, attempt, false, latencyMs, transient, errMsg(err), errCode(err), requestId, operation);
161
+ if (err instanceof AbortedError || options.signal?.aborted) {
162
+ const out2 = this.fail(requestId, operation, "aborted", "opera\xE7\xE3o abortada", latencyMs, attempts, provider.id);
163
+ this.observer?.onFinish?.(out2);
164
+ return out2;
165
+ }
166
+ if (attempt < maxAttempts && transient) {
167
+ const backoff = retries.backoffMs * Math.pow(retries.backoffMultiplier ?? 1, attempt - 1);
168
+ await sleep(clamp(backoff, 0, 5e3), options.signal).catch(() => {
169
+ });
170
+ continue;
171
+ }
172
+ lastError = this.fail(requestId, operation, errCode(err), errMsg(err), latencyMs, attempts, provider.id);
173
+ break;
174
+ }
175
+ }
176
+ }
177
+ const totalMs = Math.round(performance.now() - startedAt);
178
+ const out = lastError ?? this.fail(requestId, operation, "all_failed", "todos os provedores falharam", totalMs, attempts);
179
+ this.observer?.onFinish?.(out);
180
+ return out;
181
+ }
182
+ eligibleProviders(tenant) {
183
+ return tenant.providers.map((id) => this.providers[id]).filter((p) => p !== void 0 && p.enabled !== false);
184
+ }
185
+ resolveTenant() {
186
+ return this.policy.tenants[DEFAULT_TENANT] ?? { providers: [], operations: {} };
187
+ }
188
+ signal(timeoutMs, parent) {
189
+ if (timeoutMs <= 0 && !parent) return void 0;
190
+ const ctrl = new AbortController();
191
+ const t = timeoutMs > 0 ? setTimeout(() => ctrl.abort(), timeoutMs) : void 0;
192
+ const onParentAbort = () => ctrl.abort();
193
+ parent?.addEventListener("abort", onParentAbort, { once: true });
194
+ ctrl.signal.addEventListener("abort", () => {
195
+ if (t) clearTimeout(t);
196
+ parent?.removeEventListener("abort", onParentAbort);
197
+ });
198
+ return ctrl.signal;
199
+ }
200
+ success(requestId, providerId, operation, result, latencyMs, attempts) {
201
+ return {
202
+ id: uid("op"),
203
+ requestId,
204
+ provider: providerId,
205
+ operation,
206
+ status: "succeeded",
207
+ result,
208
+ latencyMs,
209
+ attempts,
210
+ providerReceipt: buildReceipt(providerId, requestId)
211
+ };
212
+ }
213
+ // Using overload: caller casts to TResult for ergonomics
214
+ fail(requestId, operation, code, message, latencyMs, attempts, provider) {
215
+ return {
216
+ id: uid("op"),
217
+ requestId,
218
+ provider: provider ?? "\u2014",
219
+ operation,
220
+ status: "failed",
221
+ latencyMs,
222
+ attempts,
223
+ providerReceipt: buildReceipt(provider ?? "none", requestId),
224
+ error: { code, message, transient: false, provider: provider ?? "\u2014" }
225
+ };
226
+ }
227
+ emitAttempt(provider, attempt, ok, latencyMs, transient, error, errorCode, requestId, operation) {
228
+ this.observer?.onAttempt?.({ provider, attempt, ok, latencyMs, transient, error, errorCode, requestId, operation });
229
+ }
230
+ };
231
+ function isTransient(err) {
232
+ if (!err) return false;
233
+ if (typeof err === "object" && err !== null) {
234
+ return err.transient === true;
235
+ }
236
+ return false;
237
+ }
238
+ function errCode(err) {
239
+ if (typeof err === "object" && err !== null) {
240
+ const code = err.code;
241
+ if (typeof code === "string") return code;
242
+ }
243
+ return "error";
244
+ }
245
+ function errMsg(err) {
246
+ if (typeof err === "string") return err;
247
+ if (err instanceof Error) return err.message;
248
+ if (typeof err === "object" && err !== null) {
249
+ const message = err.message;
250
+ if (typeof message === "string") return message;
251
+ }
252
+ return "unknown error";
253
+ }
254
+ export {
255
+ AbortedError,
256
+ Router,
257
+ buildReceipt,
258
+ clamp,
259
+ failover,
260
+ loadBalance,
261
+ mostFast,
262
+ roundRobin,
263
+ sleep,
264
+ strategies,
265
+ uid
266
+ };
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@layerall/core",
3
+ "version": "0.1.0",
4
+ "description": "Orchestration engine for LayerAll — types, routing strategies and provider router with retries and fallback",
5
+ "keywords": [
6
+ "orchestration",
7
+ "abstraction-layer",
8
+ "router",
9
+ "failover",
10
+ "round-robin",
11
+ "load-balance",
12
+ "strategies",
13
+ "typescript"
14
+ ],
15
+ "author": "Sidarta Veloso <sidartaveloso@gmail.com>",
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/sidartaveloso/layerall.git",
20
+ "directory": "packages/core"
21
+ },
22
+ "bugs": {
23
+ "url": "https://github.com/sidartaveloso/layerall/issues"
24
+ },
25
+ "homepage": "https://github.com/sidartaveloso/layerall#readme",
26
+ "type": "module",
27
+ "publishConfig": {
28
+ "access": "public"
29
+ },
30
+ "main": "./dist/index.js",
31
+ "types": "./dist/index.d.ts",
32
+ "exports": {
33
+ ".": {
34
+ "types": "./dist/index.d.ts",
35
+ "import": "./dist/index.js"
36
+ }
37
+ },
38
+ "files": [
39
+ "dist",
40
+ "README.md"
41
+ ],
42
+ "scripts": {
43
+ "build": "tsup src/index.ts --format esm --dts",
44
+ "dev": "tsup src/index.ts --format esm --dts --watch",
45
+ "clean": "rm -rf dist",
46
+ "lint": "tsc --noEmit",
47
+ "typecheck": "tsc --noEmit",
48
+ "test": "vitest run --coverage",
49
+ "test:watch": "vitest",
50
+ "test:no-coverage": "vitest run",
51
+ "prepublishOnly": "pnpm build"
52
+ },
53
+ "devDependencies": {
54
+ "@vitest/coverage-v8": "^2.1.0",
55
+ "tsup": "^8.3.0",
56
+ "typescript": "^5.6.0",
57
+ "vitest": "^2.1.0"
58
+ }
59
+ }