@eliware/elera-lib 0.1.8 → 0.1.9

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.9 alternative to `@eliware/mysql`; the
6
6
  existing package is intentionally unchanged. The current package version is
7
- 0.1.8.
7
+ 0.1.9.
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,
@@ -61,8 +61,10 @@ automatically.
61
61
  `client.availability()` reports whether a primary route is usable. It returns
62
62
  `state: 'cluster-unavailable'` when every primary candidate is draining or
63
63
  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`.
64
+ independently. A single-node route fails with the exported
65
+ `ServerUnavailableError` using code `SERVER_UNAVAILABLE`; a multi-node route
66
+ with no eligible candidates fails with `ClusterUnavailableError` using code
67
+ `CLUSTER_UNAVAILABLE`.
66
68
 
67
69
  When an attached routing stream receives a `routing.shutdown` event, the
68
70
  client drains the identified node, performs a REST bundle resynchronization,
@@ -94,11 +96,12 @@ contents. The stream reports `websocket`, `rest`, or `disconnected` mode so
94
96
  callers can observe transport health without implementing transport policy.
95
97
 
96
98
  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.
99
+ `createDb({ ..., telemetry: true })`. Query, execute, and transaction counts,
100
+ failures, retries, in-flight work, and latency are exposed through
101
+ `client.telemetry` and sent over an attached routing stream once per second.
102
+ Telemetry is observational only; it does not carry SQL or credentials.
103
+ Reconnect, failover, and cumulative reconnect-delay counters are included in
104
+ the telemetry snapshot.
102
105
 
103
106
  `createMaterializer` supports bounded plaintext use for a caller-provided
104
107
  operation. It creates a mode-restricted temporary file and removes its entire
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.1.9 — Standalone outage classification and complete operation telemetry
4
+
5
+ ### Added
6
+
7
+ - Exposes `ServerUnavailableError` with the stable `SERVER_UNAVAILABLE` code when a standalone SQL route has no eligible server.
8
+ - Keeps multi-node route exhaustion classified as `CLUSTER_UNAVAILABLE`.
9
+ - Records telemetry for `execute()` and transaction operations alongside query latency and failure metrics.
10
+
11
+ ### Validation
12
+
13
+ - Adds regression coverage for standalone drain classification and operation telemetry.
14
+ - Maintains 100% statements, branches, functions, and lines coverage with zero lint warnings.
15
+
3
16
  ## 0.1.8 — Explicit outage state and recovery observability
4
17
 
5
18
  ### 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.9",
4
4
  "description": "Generic MySQL and MariaDB client with resilient routing, telemetry, client-side drains, and failover",
5
5
  "keywords": [
6
6
  "eliware",
@@ -32,8 +32,8 @@ export async function createDb({ primary, balanced, bundle, credentialProvider,
32
32
  const timed = createTimedOperation({ metrics, now });
33
33
  const client = {
34
34
  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(); } },
35
+ async execute(sql, values, options) { return timed(async () => choose(sql, options).execute(sql, values)); },
36
+ 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
37
  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
38
  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
39
  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
@@ -69,6 +69,7 @@ export function validateProfile(profile: ConnectionProfile, name?: string): Conn
69
69
  export function redactedProfile(profile: ConnectionProfile): ConnectionProfile;
70
70
  export class SqlClientError extends Error { code?: string; retryable?: boolean; cause?: unknown; }
71
71
  export class ClusterUnavailableError extends SqlClientError {}
72
+ export class ServerUnavailableError extends SqlClientError {}
72
73
  export function classifyError(error: unknown): { retryable: boolean; code?: string };
73
74
  export function asSqlError(error: unknown): SqlClientError;
74
75
  export function validateBundle(bundle: RoutingBundle): RoutingBundle;
package/src/index.mjs CHANGED
@@ -3,7 +3,7 @@ export { createDbFromBundle, profilesFromBundle } from './client/from-bundle.mjs
3
3
  export { createDbFromEnvironment } from './client/environment.mjs';
4
4
  export { classifyQuery, routeFor } from './routing.mjs';
5
5
  export { validateProfile, redactedProfile } from './config.mjs';
6
- export { SqlClientError, ClusterUnavailableError, classifyError, asSqlError } from './errors.mjs';
6
+ export { SqlClientError, ClusterUnavailableError, ServerUnavailableError, classifyError, asSqlError } from './errors.mjs';
7
7
  export { validateBundle, bundleExpired, bundleNeedsRefresh } from './bundle.mjs';
8
8
  export { createAdminSql } from './admin/sql.mjs';
9
9
  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];