@eliware/elera-lib 0.1.8 → 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/README.md CHANGED
@@ -2,9 +2,9 @@
2
2
 
3
3
  The alternative SQL client for Eliware applications. It provides generic
4
4
  primary/balanced MySQL or MariaDB routing without embedding Elera, HAProxy,
5
- backup, or GitOps policy. It is a v0.1.8 alternative to `@eliware/mysql`; the
5
+ backup, or GitOps policy. It is a v0.1.10 alternative to `@eliware/mysql`; the
6
6
  existing package is intentionally unchanged. The current package version is
7
- 0.1.8.
7
+ 0.1.10.
8
8
 
9
9
  `primary` is the preferred connection path. `balanced` is an optional alternate
10
10
  path. Both may accept writes; automatic routing sends only conservative,
@@ -34,7 +34,8 @@ Routing bundles passed to `createDbFromBundle` use the normalized shape
34
34
  `routes.primary` and `routes.balanced`, each containing ordered `{ host, port,
35
35
  weight }` nodes. A bundle may also carry an explicit `writer`, ordered
36
36
  `failover`, and `readers` assignment. The bundle carries `database`,
37
- `identity`, optional `credentials`, and `expiresAt`; `validateBundle` rejects
37
+ `identity`, optional `application`, `credentialName`, `scopes`, and
38
+ `credentials`, and `expiresAt`; `validateBundle` rejects
38
39
  expired, malformed, duplicated, or conflicting route data. The checked-in
39
40
  contract fixture documents the supervisor-facing wire representation
40
41
  separately.
@@ -49,6 +50,12 @@ library does not know about supervisors, Elera, HAProxy, GitOps, backups, or
49
50
  CLI commands. Applications provide those integrations through ordinary
50
51
  configuration and callbacks.
51
52
 
53
+ An application-scoped token should resolve to one application, database, and
54
+ credential context. The library does not select databases or credentials from
55
+ request arguments. Callers that already have that authorization context may
56
+ pass `tokenContext` to `createDb`; bundle creation and refresh then reject
57
+ cross-database, identity, credential, or scope mismatches.
58
+
52
59
  When a route node is drained, the client immediately stops assigning new work
53
60
  to that node while existing operations continue. The drain window defaults to
54
61
  45 seconds and is capped at 45 seconds; remaining pool connections are then
@@ -61,8 +68,10 @@ automatically.
61
68
  `client.availability()` reports whether a primary route is usable. It returns
62
69
  `state: 'cluster-unavailable'` when every primary candidate is draining or
63
70
  unavailable; the `routes` fields report primary and balanced availability
64
- independently. New operations in that state fail with the exported
65
- `ClusterUnavailableError` using code `CLUSTER_UNAVAILABLE`.
71
+ independently. A single-node route fails with the exported
72
+ `ServerUnavailableError` using code `SERVER_UNAVAILABLE`; a multi-node route
73
+ with no eligible candidates fails with `ClusterUnavailableError` using code
74
+ `CLUSTER_UNAVAILABLE`.
66
75
 
67
76
  When an attached routing stream receives a `routing.shutdown` event, the
68
77
  client drains the identified node, performs a REST bundle resynchronization,
@@ -94,11 +103,14 @@ contents. The stream reports `websocket`, `rest`, or `disconnected` mode so
94
103
  callers can observe transport health without implementing transport policy.
95
104
 
96
105
  Applications may opt into generic in-memory telemetry with
97
- `createDb({ ..., telemetry: true })`. Query counts, failures, retries,
98
- in-flight work, and latency are exposed through `client.telemetry` and sent
99
- over an attached routing stream once per second. Telemetry is observational
100
- only; it does not carry SQL or credentials. Reconnect, failover, and cumulative
101
- reconnect-delay counters are included in the telemetry snapshot.
106
+ `createDb({ ..., telemetry: true })`. Query, execute, and transaction counts,
107
+ failures, retries, in-flight work, and latency are exposed through
108
+ `client.telemetry` and sent over an attached routing stream once per second.
109
+ Telemetry is observational only; it does not carry SQL or credentials.
110
+ The snapshot may include the application, database, credential name, and
111
+ scopes associated with the already-authorized client, plus reconnect, failover,
112
+ and cumulative reconnect-delay counters. It never includes bearer tokens or
113
+ passwords.
102
114
 
103
115
  `createMaterializer` supports bounded plaintext use for a caller-provided
104
116
  operation. It creates a mode-restricted temporary file and removes its entire
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.1.10 — Token-bound routing context
4
+
5
+ ### Added
6
+
7
+ - Adds `validateTokenContext` for enforcing an application token's authorized
8
+ application, database, credential, identity, and scopes against a routing
9
+ bundle.
10
+ - Applies that authorization check during initial client creation and every
11
+ subsequent bundle refresh.
12
+ - Keeps routing-stream authorization token-only; database and application
13
+ selectors are not sent as query parameters.
14
+ - Includes safe application, database, credential, and scope context in
15
+ opt-in in-memory telemetry without exposing tokens or passwords.
16
+
17
+ ### Validation
18
+
19
+ - Adds focused coverage for matching contexts, rejected cross-database and
20
+ scope mismatches, refresh-time enforcement, and isolated token contexts.
21
+ - Maintains 100% statements, branches, functions, and lines coverage with zero
22
+ lint warnings.
23
+ - Typecheck, contract, syntax, and package dry-run checks pass.
24
+
25
+ ## 0.1.9 — Standalone outage classification and complete operation telemetry
26
+
27
+ ### Added
28
+
29
+ - Exposes `ServerUnavailableError` with the stable `SERVER_UNAVAILABLE` code when a standalone SQL route has no eligible server.
30
+ - Keeps multi-node route exhaustion classified as `CLUSTER_UNAVAILABLE`.
31
+ - Records telemetry for `execute()` and transaction operations alongside query latency and failure metrics.
32
+
33
+ ### Validation
34
+
35
+ - Adds regression coverage for standalone drain classification and operation telemetry.
36
+ - Maintains 100% statements, branches, functions, and lines coverage with zero lint warnings.
37
+
3
38
  ## 0.1.8 — Explicit outage state and recovery observability
4
39
 
5
40
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eliware/elera-lib",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Generic MySQL and MariaDB client with resilient routing, telemetry, client-side drains, and failover",
5
5
  "keywords": [
6
6
  "eliware",
@@ -0,0 +1,9 @@
1
+ export function validateTokenContext(bundle, tokenContext = {}) {
2
+ if (!bundle || typeof bundle !== 'object') throw new TypeError('routing bundle is required');
3
+ for (const field of ['application', 'database', 'credentialName']) {
4
+ if (tokenContext[field] !== undefined && bundle[field] !== tokenContext[field]) throw new Error(`routing bundle ${field} does not match token context`);
5
+ }
6
+ if (tokenContext.identity !== undefined && bundle.identity !== tokenContext.identity) throw new Error('routing bundle identity does not match token context');
7
+ if (tokenContext.scopes !== undefined && (!Array.isArray(bundle.scopes) || tokenContext.scopes.some((scope) => !bundle.scopes.includes(scope)))) throw new Error('routing bundle scopes exceed token context');
8
+ return bundle;
9
+ }
@@ -3,17 +3,18 @@ import * as mysql from 'mysql2/promise';
3
3
  import { validateProfile, redactedProfile } from '../config.mjs';
4
4
  import { asSqlError } from '../errors.mjs';
5
5
  import { resolveCredentials, credentialContext } from '../credential-provider.mjs';
6
- import { validateBundle, bundleExpired, bundleNeedsRefresh } from '../bundle.mjs';
6
+ import { validateBundle as validateBundleShape, bundleExpired, bundleNeedsRefresh } from '../bundle.mjs';
7
7
  import { createRouteFactory } from './route-factory.mjs';
8
8
  import { classifyQuery, routeFor } from '../routing.mjs';
9
9
  import { compareBundleVersions } from '../routing/bundle-version.mjs';
10
10
  import { clientDrainTimeout } from '../lifecycle/drain-policy.mjs';
11
11
  import { createTelemetry } from '../telemetry.mjs';
12
12
  import { createTimedOperation } from './telemetry-wrapper.mjs';
13
+ import { validateTokenContext } from './authorization-context.mjs';
13
14
 
14
15
  const olderVersion = (candidate, current) => compareBundleVersions(candidate, current) < 0;
15
16
 
16
- export async function createDb({ primary, balanced, bundle, credentialProvider, mysqlLib = mysql, log = defaultLog, routing = 'auto', identity, quarantineMs = 5000, drainTimeoutMs = 45000, now = () => Date.now(), telemetry } = {}) {
17
+ export async function createDb({ primary, balanced, bundle, credentialProvider, mysqlLib = mysql, log = defaultLog, routing = 'auto', identity, tokenContext, quarantineMs = 5000, drainTimeoutMs = 45000, now = () => Date.now(), telemetry } = {}) {
17
18
  if (!primary || typeof primary !== 'object') throw new TypeError('primary connection profile is required');
18
19
  const credentials = await resolveCredentials(credentialProvider, credentialContext(primary, { identity }));
19
20
  let primaryConfig = validateProfile({ ...primary, ...credentials }, 'primary');
@@ -23,17 +24,18 @@ export async function createDb({ primary, balanced, bundle, credentialProvider,
23
24
  primaryConfig = validateProfile({ ...primaryConfig, ...credentials }, 'primary');
24
25
  if (balancedConfig) balancedConfig = validateProfile({ ...balancedConfig, ...credentials }, 'balanced');
25
26
  }
27
+ const validateBundle = (candidate) => validateTokenContext(validateBundleShape(candidate), tokenContext);
26
28
  let activeBundle = bundle ? validateBundle(bundle) : undefined;
27
29
  const makeRoute = (route, fallback) => createRouteFactory({ bundle: activeBundle, now, mysqlLib, log, quarantineMs })(route, fallback);
28
30
  let primaryPool = makeRoute('primary', primaryConfig);
29
31
  let balancedPool = balancedConfig || activeBundle?.routes?.balanced ? makeRoute('balanced', balancedConfig ?? primaryConfig) : null;
30
32
  const choose = (sql, options = {}) => options.connection ?? (routeFor(sql, options.route ?? routing) === 'balanced' && balancedPool ? balancedPool : primaryPool);
31
- const metrics = telemetry === true ? createTelemetry({ application: bundle?.application ?? identity ?? 'default', now }) : telemetry;
33
+ const metrics = telemetry === true ? createTelemetry({ application: bundle?.application ?? 'default', credentialName: bundle?.credentialName, database: bundle?.database, scopes: bundle?.scopes, now }) : telemetry;
32
34
  const timed = createTimedOperation({ metrics, now });
33
35
  const client = {
34
36
  async query(sql, values, options) { return timed(async () => { const selected = choose(sql, options); try { return await selected.query(sql, values); } catch (error) { const requestedRoute = options?.route ?? routing; if (error.retryable && balancedPool && routeFor(sql, requestedRoute) === 'balanced' && classifyQuery(sql) === 'balanced') { metrics?.record?.({ retry: true }); return balancedPool.query(sql, values); } throw error; } }); },
35
- async execute(sql, values, options) { return choose(sql, options).execute(sql, values); },
36
- async transaction(callback) { const node = primaryPool.choose(); const connection = await node.getConnection(); try { await connection.beginTransaction(); const tx = { query: (sql, values) => connection.query(sql, values), execute: (sql, values) => connection.execute(sql, values) }; const result = await callback(tx); await connection.commit(); return result; } catch (error) { await connection.rollback().catch(() => {}); throw asSqlError(error); } finally { connection.release(); } },
37
+ async execute(sql, values, options) { return timed(async () => choose(sql, options).execute(sql, values)); },
38
+ async transaction(callback) { return timed(async () => { const node = primaryPool.choose(); const connection = await node.getConnection(); try { await connection.beginTransaction(); const tx = { query: (sql, values) => connection.query(sql, values), execute: (sql, values) => connection.execute(sql, values) }; const result = await callback(tx); await connection.commit(); return result; } catch (error) { await connection.rollback().catch(() => {}); throw asSqlError(error); } finally { connection.release(); } }); },
37
39
  async health(route = 'primary') { const started = now(); const selected = route === 'balanced' && balancedPool ? balancedPool : primaryPool; const nodes = await selected.health(); return { ok: nodes.some((node) => node.ok), route: selected === balancedPool ? 'balanced' : 'primary', nodes, latencyMs: now() - started }; },
38
40
  async refresh(nextBundle) { const candidate = validateBundle(nextBundle); if (bundleExpired(candidate, now())) throw new Error('routing bundle is expired'); if (olderVersion(candidate.bundleVersion, activeBundle?.bundleVersion)) return { bundleVersion: activeBundle?.bundleVersion, refreshRequired: bundleNeedsRefresh(activeBundle, now()) }; const previous = [primaryPool, balancedPool]; const credentials = candidate.credentials ?? { username: primaryConfig.user, password: primaryConfig.password }; const writer = candidate.writer ?? candidate.routes.primary?.[0]; const reader = candidate.readers?.[0] ?? candidate.routes.balanced?.[0]; primaryConfig = validateProfile({ ...primaryConfig, host: writer.host, port: writer.port, user: credentials.username, password: credentials.password, database: candidate.database }, 'primary'); balancedConfig = reader ? validateProfile({ ...primaryConfig, host: reader.host, port: reader.port }, 'balanced') : undefined; activeBundle = candidate; primaryPool = makeRoute('primary', primaryConfig); balancedPool = balancedConfig ? makeRoute('balanced', balancedConfig) : null; await Promise.all(previous.filter(Boolean).map((pool) => pool.close())); return { bundleVersion: activeBundle.bundleVersion ?? null, refreshRequired: bundleNeedsRefresh(activeBundle, now()) }; },
39
41
  async attachRoutingStream(stream) { if (!stream?.connect) throw new TypeError('routing stream is required'); metrics?.start?.(stream); stream.setTelemetry?.(metrics); stream.setOnUpdate?.(async (event) => { const update = event.type === 'routing.update' ? event : event.type === 'routing.resync' ? event.bundle : undefined; if (update && (update.writer || update.routes?.primary?.length)) await client.refresh({ ...activeBundle, ...update, database: update.database ?? activeBundle?.database ?? primaryConfig.database, credentials: update.credentials ?? activeBundle?.credentials, routes: update.routes ?? activeBundle?.routes, bundleVersion: update.bundleVersion ?? update.version ?? activeBundle?.bundleVersion, expiresAt: update.expiresAt ?? activeBundle?.expiresAt ?? new Date(now() + 60000).toISOString() }); if (event.type === 'routing.drain' || event.type === 'routing.shutdown') for (const pool of [primaryPool, balancedPool].filter(Boolean)) (event.type === 'routing.drain' ? pool.drain : pool.drain)(event.node, drainTimeoutMs); if (event.type === 'routing.recovery') for (const pool of [primaryPool, balancedPool].filter(Boolean)) pool.recover(event.node, drainTimeoutMs); }); await stream.connect(); return () => stream.close?.(); },
package/src/errors.mjs CHANGED
@@ -11,6 +11,13 @@ export class ClusterUnavailableError extends SqlClientError {
11
11
  }
12
12
  }
13
13
 
14
+ export class ServerUnavailableError extends SqlClientError {
15
+ constructor(message = 'The SQL server is unavailable', options = {}) {
16
+ super(message, { code: 'SERVER_UNAVAILABLE', retryable: false, ...options });
17
+ this.name = 'ServerUnavailableError';
18
+ }
19
+ }
20
+
14
21
  export function classifyError(error) {
15
22
  const code = error?.code;
16
23
  if (['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'PROTOCOL_CONNECTION_LOST', 'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR'].includes(code)) return { code: 'CONNECTION_ERROR', retryable: true };
package/src/index.d.ts CHANGED
@@ -15,6 +15,9 @@ export interface RoutingBundle {
15
15
  apiVersion?: string;
16
16
  database?: string;
17
17
  identity?: string;
18
+ application?: string;
19
+ credentialName?: string;
20
+ scopes?: string[];
18
21
  credentials?: { username?: string; password?: string };
19
22
  bundleVersion?: number | string;
20
23
  expiresAt: string;
@@ -28,7 +31,7 @@ export interface CredentialProviderResult { user: string; password: string; }
28
31
  export type CredentialProvider = (context: { database: string; identity: string | null; route: string }) => Promise<CredentialProviderResult> | CredentialProviderResult;
29
32
  export type QueryFunction = (sql: string, values?: unknown) => Promise<any>;
30
33
  export interface DbOptions { route?: 'auto' | 'primary' | 'balanced'; connection?: unknown; }
31
- export interface TelemetrySnapshot { type: 'client.telemetry'; application: string; queries: number; failures: number; retries: number; reconnects: number; failoverCount: number; reconnectDelayMs: number; inflight: number; totalLatencyMs: number; maxLatencyMs: number; avgLatencyMs: number; sentAt: string; }
34
+ export interface TelemetrySnapshot { type: 'client.telemetry'; application: string; credentialName?: string; database?: string; scopes?: string[]; queries: number; failures: number; retries: number; reconnects: number; failoverCount: number; reconnectDelayMs: number; inflight: number; totalLatencyMs: number; maxLatencyMs: number; avgLatencyMs: number; sentAt: string; }
32
35
  export interface Telemetry { begin(): number; record(event?: { latencyMs?: number; failed?: boolean; retry?: boolean; reconnect?: boolean; failover?: boolean }): void; recordReconnect(event?: { delayMs?: number; failover?: boolean }): void; snapshot(): TelemetrySnapshot; start(stream: Pick<RoutingStream, 'sendTelemetry'>): void; stop(): void; }
33
36
 
34
37
  export interface DbClient {
@@ -61,7 +64,7 @@ export interface RoutingStream {
61
64
  export type RoutingEvent = { type: 'routing.update' | 'routing.resync' | 'routing.drain' | 'routing.recovery'; node?: string; version?: number | string; [key: string]: unknown } | { type: 'routing.shutdown'; node?: string; reason?: string; reconnect?: boolean; reconnectDeadlineMs?: number; loadBalancerEndpoint?: string };
62
65
  export function validateRoutingEvent(event: unknown): RoutingEvent;
63
66
 
64
- export function createDb(options: { primary: ConnectionProfile; balanced?: Partial<ConnectionProfile>; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; quarantineMs?: number; drainTimeoutMs?: number; now?: () => number; telemetry?: true | Telemetry }): Promise<DbClient>;
67
+ export function createDb(options: { primary: ConnectionProfile; balanced?: Partial<ConnectionProfile>; bundle?: RoutingBundle; tokenContext?: { application?: string; database?: string; credentialName?: string; identity?: string; scopes?: string[] }; credentialProvider?: CredentialProvider; identity?: string; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; quarantineMs?: number; drainTimeoutMs?: number; now?: () => number; telemetry?: true | Telemetry }): Promise<DbClient>;
65
68
  export function createDbFromEnvironment(options?: { env?: Record<string, string | undefined>; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string; telemetry?: true | Telemetry }): Promise<DbClient>;
66
69
  export function classifyQuery(sql: unknown): 'primary' | 'balanced';
67
70
  export function routeFor(sql: unknown, requested?: 'auto' | 'primary' | 'balanced'): 'primary' | 'balanced';
@@ -69,6 +72,7 @@ export function validateProfile(profile: ConnectionProfile, name?: string): Conn
69
72
  export function redactedProfile(profile: ConnectionProfile): ConnectionProfile;
70
73
  export class SqlClientError extends Error { code?: string; retryable?: boolean; cause?: unknown; }
71
74
  export class ClusterUnavailableError extends SqlClientError {}
75
+ export class ServerUnavailableError extends SqlClientError {}
72
76
  export function classifyError(error: unknown): { retryable: boolean; code?: string };
73
77
  export function asSqlError(error: unknown): SqlClientError;
74
78
  export function validateBundle(bundle: RoutingBundle): RoutingBundle;
@@ -77,7 +81,7 @@ export function bundleNeedsRefresh(bundle: RoutingBundle, now?: number): boolean
77
81
  export function createAdminSql(options: { query: QueryFunction }): { transaction<T>(work: (context: { query: QueryFunction }) => Promise<T>): Promise<T>; migration(statements?: string[]): Promise<unknown> };
78
82
  export function createMigrationRunner(options: { query: QueryFunction; migrations?: Array<{ version: number; name: string; statements: string[] }> }): { status(): Promise<{ applied: unknown[] }>; migrate(): Promise<unknown> };
79
83
  export function selectRouteNodes(options: { bundle: RoutingBundle; route?: 'primary' | 'balanced'; now?: number }): RoutingNode[];
80
- export function createRoutingStream(options: { endpoint: string; token?: string; application?: string; fetchBundle: (application: string) => Promise<RoutingBundle>; onUpdate?: (event: unknown) => void; onError?: (error: unknown) => void; reconnectMs?: number; maxReconnectMs?: number; heartbeatMs?: number; telemetry?: Pick<Telemetry, 'recordReconnect'> }): RoutingStream;
84
+ export function createRoutingStream(options: { endpoint: string; token?: string; fetchBundle: () => Promise<RoutingBundle>; onUpdate?: (event: unknown) => void; onError?: (error: unknown) => void; reconnectMs?: number; maxReconnectMs?: number; heartbeatMs?: number; telemetry?: Pick<Telemetry, 'recordReconnect'> }): RoutingStream;
81
85
  export function writerAssignment(bundle: RoutingBundle): WriterAssignment;
82
86
  export function failoverNodes(bundle: RoutingBundle): WriterAssignment[];
83
87
  export function compareBundleVersions(left: number | string | undefined, right: number | string | undefined): number;
@@ -86,4 +90,4 @@ export function clientDrainTimeout(timeoutMs?: number): number;
86
90
  export function createQuiesceController(options?: { close?: () => Promise<void>; onChange?: (state: string) => void }): { state(): { state: string; active: number }; enter(): () => void; begin(): Promise<void>; end(): Promise<void>; close(): Promise<void> };
87
91
  export function createSqlVerifier(options: { query: QueryFunction }): { connectivity(): Promise<{ verified: boolean }>; schema(database: string): Promise<{ database: string; verified: boolean }>; account(user: string, host?: string): Promise<{ user: string; host: string; verified: boolean; grants: string[] }>; all(options?: { database?: string; user?: string; host?: string }): Promise<unknown> };
88
92
  export function createMaterializer(options?: Record<string, unknown>): unknown;
89
- export function createTelemetry(options?: { application?: string; intervalMs?: number; now?: () => number; setIntervalImpl?: typeof setInterval; clearIntervalImpl?: typeof clearInterval }): Telemetry;
93
+ export function createTelemetry(options?: { application?: string; credentialName?: string; database?: string; scopes?: string[]; intervalMs?: number; now?: () => number; setIntervalImpl?: typeof setInterval; clearIntervalImpl?: typeof clearInterval }): Telemetry;
package/src/index.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  export { createDb } from './client/create-db.mjs';
2
+ export { validateTokenContext } from './client/authorization-context.mjs';
2
3
  export { createDbFromBundle, profilesFromBundle } from './client/from-bundle.mjs';
3
4
  export { createDbFromEnvironment } from './client/environment.mjs';
4
5
  export { classifyQuery, routeFor } from './routing.mjs';
5
6
  export { validateProfile, redactedProfile } from './config.mjs';
6
- export { SqlClientError, ClusterUnavailableError, classifyError, asSqlError } from './errors.mjs';
7
+ export { SqlClientError, ClusterUnavailableError, ServerUnavailableError, classifyError, asSqlError } from './errors.mjs';
7
8
  export { validateBundle, bundleExpired, bundleNeedsRefresh } from './bundle.mjs';
8
9
  export { createAdminSql } from './admin/sql.mjs';
9
10
  export { createMigrationRunner } from './admin/migrations.mjs';
@@ -1,11 +1,11 @@
1
- import { ClusterUnavailableError } from '../errors.mjs';
1
+ import { ClusterUnavailableError, ServerUnavailableError } from '../errors.mjs';
2
2
 
3
- export function createRoutePool(nodes, { preferred = false } = {}) {
3
+ export function createRoutePool(nodes, { preferred = false, unavailableError = nodes.length === 1 ? ServerUnavailableError : ClusterUnavailableError } = {}) {
4
4
  let cursor = 0;
5
5
  const candidates = () => nodes.filter((node) => node.available);
6
6
  const choose = () => {
7
7
  const available = candidates();
8
- if (!available.length) throw new ClusterUnavailableError('no eligible SQL nodes available');
8
+ if (!available.length) throw new unavailableError('no eligible SQL nodes available');
9
9
  if (preferred) return available[0];
10
10
  const total = available.reduce((sum, node) => sum + Math.max(0, node.weight), 0);
11
11
  if (!total) return available[cursor++ % available.length];
@@ -2,12 +2,12 @@ import { log as defaultLog } from '@eliware/common';
2
2
  import { compareBundleVersions } from './bundle-version.mjs';
3
3
  import { validateRoutingEvent } from './event-contract.mjs';
4
4
 
5
- export function createRoutingStream({ endpoint, token, application = 'default', fetchBundle, WebSocketImpl = globalThis.WebSocket, onUpdate, onError, reconnectMs = 1000, maxReconnectMs = 30000, heartbeatMs = 45000, now = () => Date.now(), telemetry } = {}) {
5
+ export function createRoutingStream({ endpoint, token, fetchBundle, WebSocketImpl = globalThis.WebSocket, onUpdate, onError, reconnectMs = 1000, maxReconnectMs = 30000, heartbeatMs = 45000, now = () => Date.now(), telemetry } = {}) {
6
6
  if (!endpoint || typeof fetchBundle !== 'function') throw new TypeError('endpoint and fetchBundle are required');
7
7
  let socket; let closed = false; let timer; let heartbeat; let expectedVersion = 0; let delay = reconnectMs; let updateHandler = onUpdate; let mode = 'disconnected'; let plannedReconnect = false; let lastReconnectWasPlanned = false; let disconnectedAt; let reconnectDeadlineAt;
8
8
  const log = arguments[0]?.log ?? defaultLog;
9
- const streamUrl = () => `${endpoint.replace(/^http/i, 'ws').replace(/\/$/, '')}/api/v1/routing/stream?application=${encodeURIComponent(application)}&token=${encodeURIComponent(token ?? '')}`;
10
- async function fallback() { try { const bundle = await fetchBundle(application); if (closed) return; mode = 'rest'; updateHandler?.({ type: 'routing.resync', version: expectedVersion, bundle, receivedAt: now() }); } catch (error) { if (closed) return; mode = 'disconnected'; onError?.(error); log.warn?.('Routing REST fallback failed', { error }); } }
9
+ const streamUrl = () => `${endpoint.replace(/^http/i, 'ws').replace(/\/$/, '')}/api/v1/routing/stream?token=${encodeURIComponent(token ?? '')}`;
10
+ async function fallback() { try { const bundle = await fetchBundle(); if (closed) return; mode = 'rest'; updateHandler?.({ type: 'routing.resync', version: expectedVersion, bundle, receivedAt: now() }); } catch (error) { if (closed) return; mode = 'disconnected'; onError?.(error); log.warn?.('Routing REST fallback failed', { error }); } }
11
11
  function schedule() { if (closed || timer || (reconnectDeadlineAt !== undefined && now() >= reconnectDeadlineAt)) return; const wait = Math.min(delay, Math.max(0, reconnectDeadlineAt === undefined ? delay : reconnectDeadlineAt - now())); timer = setTimeout(() => { timer = undefined; void connect(); }, wait); delay = Math.min(maxReconnectMs, delay * 2); }
12
12
  async function connect() {
13
13
  if (closed || typeof WebSocketImpl !== 'function') { await fallback(); schedule(); return; }
package/src/telemetry.mjs CHANGED
@@ -1,9 +1,9 @@
1
- export function createTelemetry({ application = 'default', intervalMs = 1000, now = () => Date.now(), setIntervalImpl = setInterval, clearIntervalImpl = clearInterval } = {}) {
1
+ export function createTelemetry({ application = 'default', credentialName = undefined, database = undefined, scopes = undefined, intervalMs = 1000, now = () => Date.now(), setIntervalImpl = setInterval, clearIntervalImpl = clearInterval } = {}) {
2
2
  const stats = { queries: 0, failures: 0, retries: 0, reconnects: 0, failoverCount: 0, reconnectDelayMs: 0, inflight: 0, totalLatencyMs: 0, maxLatencyMs: 0 };
3
3
  let timer;
4
4
  const begin = () => { stats.inflight += 1; return now(); };
5
5
  const record = ({ latencyMs = 0, failed = false, retry = false, reconnect = false, failover = false } = {}) => { stats.queries += 1; stats.inflight = Math.max(0, stats.inflight - 1); stats.totalLatencyMs += latencyMs; stats.maxLatencyMs = Math.max(stats.maxLatencyMs, latencyMs); if (failed) stats.failures += 1; if (retry) stats.retries += 1; if (reconnect) stats.reconnects += 1; if (failover) stats.failoverCount += 1; };
6
- const snapshot = () => ({ type: 'client.telemetry', application, ...stats, avgLatencyMs: stats.queries ? stats.totalLatencyMs / stats.queries : 0, sentAt: new Date(now()).toISOString() });
6
+ const snapshot = () => ({ type: 'client.telemetry', application, credentialName, database, scopes, ...stats, avgLatencyMs: stats.queries ? stats.totalLatencyMs / stats.queries : 0, sentAt: new Date(now()).toISOString() });
7
7
  const recordReconnect = ({ delayMs = 0, failover = false } = {}) => { stats.reconnects += 1; stats.reconnectDelayMs += Math.max(0, Number(delayMs) || 0); if (failover) stats.failoverCount += 1; };
8
8
  return { begin, record, recordReconnect, snapshot, start(stream) { if (timer) return; timer = setIntervalImpl(() => stream.sendTelemetry?.(snapshot()), intervalMs); timer.unref?.(); }, stop() { if (timer) clearIntervalImpl(timer); timer = undefined; } };
9
9
  }