@eliware/elera-lib 0.1.7 → 0.1.8

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.7 alternative to `@eliware/mysql`; the
5
+ backup, or GitOps policy. It is a v0.1.8 alternative to `@eliware/mysql`; the
6
6
  existing package is intentionally unchanged. The current package version is
7
- 0.1.7.
7
+ 0.1.8.
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,
@@ -58,6 +58,12 @@ state. Only conservative, single-statement reads are eligible for automatic
58
58
  retry after a connection failure; uncertain writes are never retried
59
59
  automatically.
60
60
 
61
+ `client.availability()` reports whether a primary route is usable. It returns
62
+ `state: 'cluster-unavailable'` when every primary candidate is draining or
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`.
66
+
61
67
  When an attached routing stream receives a `routing.shutdown` event, the
62
68
  client drains the identified node, performs a REST bundle resynchronization,
63
69
  and closes the retiring WebSocket with restart code `1012`. If the event
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.1.8 — Explicit outage state and recovery observability
4
+
5
+ ### Added
6
+
7
+ - Exposes `ClusterUnavailableError` with the stable `CLUSTER_UNAVAILABLE` code
8
+ when no eligible SQL node can serve a route.
9
+ - Adds `DbClient.availability()` so applications can distinguish an available
10
+ client from a fully unavailable primary route, including per-route status.
11
+ - Covers standalone node drain, total primary-route outage, and route recovery.
12
+ - Updates the TypeScript declarations for the new runtime API and error type.
13
+
14
+ ### Validation
15
+
16
+ - Maintains 100% statements, branches, functions, and lines coverage.
17
+ - Tests and TypeScript typechecking pass with zero lint warnings.
18
+
3
19
  ## 0.1.7 — Routing shutdown contract completion
4
20
 
5
21
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eliware/elera-lib",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
4
4
  "description": "Generic MySQL and MariaDB client with resilient routing, telemetry, client-side drains, and failover",
5
5
  "keywords": [
6
6
  "eliware",
@@ -35,9 +35,10 @@ export async function createDb({ primary, balanced, bundle, credentialProvider,
35
35
  async execute(sql, values, options) { return choose(sql, options).execute(sql, values); },
36
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
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
- 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()) }; },
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?.(); },
40
40
  drain(host, timeoutMs = drainTimeoutMs) { const effectiveTimeout = clientDrainTimeout(timeoutMs); const pools = [primaryPool, balancedPool].filter(Boolean); pools.forEach((pool) => pool.drain(host, effectiveTimeout)); return { host, timeoutMs: effectiveTimeout, wait: () => Promise.all(pools.map((pool) => pool.waitForIdle(effectiveTimeout))), forceClose: () => Promise.all(pools.map((pool) => pool.forceClose(host))) }; },
41
+ availability() { const states = this.nodeStates(); const primaryAvailable = states.some((node) => node.route === 'primary' && node.available); return { state: primaryAvailable ? 'available' : 'cluster-unavailable', routes: { primary: primaryAvailable, balanced: states.some((node) => node.route === 'balanced' && node.available) } }; },
41
42
  nodeStates() { return [primaryPool, balancedPool].filter(Boolean).flatMap((pool) => pool.nodes.map((node) => ({ host: node.host, port: node.port, route: pool === primaryPool ? 'primary' : 'balanced', state: node.state, active: node.active, available: node.available }))); },
42
43
  setNodeAvailability(route, host, available) { const pool = route === 'balanced' ? balancedPool : primaryPool; pool?.setAvailability(host, available); },
43
44
  bundle: () => activeBundle,
package/src/errors.mjs CHANGED
@@ -4,6 +4,13 @@ export class SqlClientError extends Error {
4
4
  }
5
5
  }
6
6
 
7
+ export class ClusterUnavailableError extends SqlClientError {
8
+ constructor(message = 'No eligible SQL nodes are available', options = {}) {
9
+ super(message, { code: 'CLUSTER_UNAVAILABLE', retryable: false, ...options });
10
+ this.name = 'ClusterUnavailableError';
11
+ }
12
+ }
13
+
7
14
  export function classifyError(error) {
8
15
  const code = error?.code;
9
16
  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
@@ -42,6 +42,7 @@ export interface DbClient {
42
42
  classify(sql: string): 'primary' | 'balanced';
43
43
  attachRoutingStream(stream: RoutingStream): Promise<() => void>;
44
44
  setNodeAvailability(route: 'primary' | 'balanced', host: string, available: boolean): void;
45
+ availability(): { state: 'available' | 'cluster-unavailable'; routes: { primary: boolean; balanced: boolean } };
45
46
  drain(host: string, timeoutMs?: number): { host: string; timeoutMs: number; wait(): Promise<unknown[]>; forceClose(): Promise<unknown[]> };
46
47
  nodeStates(): Array<{ host: string; port: number; route: 'primary' | 'balanced'; state: 'ready' | 'draining' | 'unavailable' | 'recovering'; active: number; available: boolean }>;
47
48
  config: { primary: ConnectionProfile; balanced?: ConnectionProfile };
@@ -67,6 +68,7 @@ export function routeFor(sql: unknown, requested?: 'auto' | 'primary' | 'balance
67
68
  export function validateProfile(profile: ConnectionProfile, name?: string): ConnectionProfile;
68
69
  export function redactedProfile(profile: ConnectionProfile): ConnectionProfile;
69
70
  export class SqlClientError extends Error { code?: string; retryable?: boolean; cause?: unknown; }
71
+ export class ClusterUnavailableError extends SqlClientError {}
70
72
  export function classifyError(error: unknown): { retryable: boolean; code?: string };
71
73
  export function asSqlError(error: unknown): SqlClientError;
72
74
  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, classifyError, asSqlError } from './errors.mjs';
6
+ export { SqlClientError, ClusterUnavailableError, 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,9 +1,11 @@
1
+ import { ClusterUnavailableError } from '../errors.mjs';
2
+
1
3
  export function createRoutePool(nodes, { preferred = false } = {}) {
2
4
  let cursor = 0;
3
5
  const candidates = () => nodes.filter((node) => node.available);
4
6
  const choose = () => {
5
7
  const available = candidates();
6
- if (!available.length) throw new Error('no eligible SQL nodes available');
8
+ if (!available.length) throw new ClusterUnavailableError('no eligible SQL nodes available');
7
9
  if (preferred) return available[0];
8
10
  const total = available.reduce((sum, node) => sum + Math.max(0, node.weight), 0);
9
11
  if (!total) return available[cursor++ % available.length];