@eliware/elera-lib 0.1.0 → 0.1.1

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,7 +2,7 @@
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.0 alternative to `@eliware/mysql`; the
5
+ backup, or GitOps policy. It is a v0.1.1 alternative to `@eliware/mysql`; the
6
6
  existing package is intentionally unchanged.
7
7
 
8
8
  `primary` is the preferred connection path. `balanced` is an optional alternate
@@ -19,7 +19,9 @@ await db.close();
19
19
  Environment variables: `MYSQL_PRIMARY_HOST` (or `MYSQL_HOST`),
20
20
  `MYSQL_PRIMARY_PORT` (or `MYSQL_PORT`), optional `MYSQL_BALANCED_HOST`,
21
21
  optional `MYSQL_BALANCED_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, and
22
- `MYSQL_DATABASE`. Pool settings may be supplied with `MYSQL_CONNECT_TIMEOUT`,
22
+ `MYSQL_DATABASE`. `MYSQL_SOCKET` optionally selects a Unix-domain socket for
23
+ the primary connection, which is useful for local socket-authenticated MariaDB
24
+ accounts. Pool settings may be supplied with `MYSQL_CONNECT_TIMEOUT`,
23
25
  `MYSQL_ACQUIRE_TIMEOUT`, `MYSQL_CONNECTION_LIMIT`, `MYSQL_QUEUE_LIMIT`, and
24
26
  `MYSQL_SSL`. Configure primary and balanced routes explicitly; applications
25
27
  should not rely on ambiguous single-endpoint aliases.
@@ -34,8 +36,9 @@ wire representation separately.
34
36
  The implementation accepts optional routing bundles and injected credential
35
37
  providers, maintains bounded pools per route, supports ordered writer/reader
36
38
  candidates, bundle refresh, and quarantine of unhealthy nodes. The WebSocket
37
- routing-event transport is implemented as a generic adapter. These are generic
38
- client capabilities: the
39
+ routing-event transport is implemented as a generic adapter. Nodes can be
40
+ immediately excluded or re-admitted with `client.setNodeAvailability(route,
41
+ host, available)`. These are generic client capabilities: the
39
42
  library does not know about supervisors, Elera, HAProxy, GitOps, backups, or
40
43
  CLI commands. Applications provide those integrations through ordinary
41
44
  configuration and callbacks.
@@ -48,7 +51,8 @@ WebSocket transports are adapters, not supervisor or CLI policy. Underlying
48
51
  For maintenance workflows, `createQuiesceController` provides a generic
49
52
  connection-admission drain and `createSqlVerifier` provides generic connectivity,
50
53
  schema, account, and grant checks. Neither API transports or orchestrates dump
51
- contents.
54
+ contents. The stream reports `websocket`, `rest`, or `disconnected` mode so
55
+ callers can observe transport health without implementing transport policy.
52
56
 
53
57
  `createMaterializer` supports bounded plaintext use for a caller-provided
54
58
  operation. It creates a mode-restricted temporary file and removes its entire
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.1.1 — Runtime integration readiness
4
+
5
+ This unreleased patch prepares the generic client for supervisor control-plane
6
+ connections and application-side routing changes. It does not add supervisor,
7
+ CLI, Galera, backup, or GitOps policy to the library.
8
+
9
+ ### Connection and routing lifecycle
10
+
11
+ - Supports optional `MYSQL_SOCKET` Unix-domain socket connections for local
12
+ MariaDB control-plane use while preserving TCP configuration.
13
+ - Adds explicit route-node availability control for immediate drain and
14
+ recovery exclusion without exposing pool internals.
15
+ - Keeps manually excluded nodes unavailable until explicitly restored, rather
16
+ than allowing a quarantine timer to re-admit them.
17
+
18
+ ### Routing stream behavior
19
+
20
+ - Reports whether routing updates are currently using WebSocket, REST fallback,
21
+ or have no usable transport.
22
+ - Prevents late REST fallback results from changing state after stream shutdown.
23
+ - Prevents reconnect scheduling and fallback callbacks from reviving a closed
24
+ stream.
25
+
26
+ ### Validation
27
+
28
+ - Adds regression coverage for socket environment mapping, route exclusion,
29
+ stream shutdown, fallback state, and child lifecycle behavior.
30
+ - Maintains 100% statements, branches, functions, and lines coverage with zero
31
+ lint warnings.
32
+
3
33
  ## 0.1.0 — Baseline release
4
34
 
5
35
  `@eliware/elera-lib` is a generic MariaDB/MySQL client library for applications
package/package.json CHANGED
@@ -1,12 +1,28 @@
1
1
  {
2
2
  "name": "@eliware/elera-lib",
3
- "version": "0.1.0",
4
- "description": "Eliware SQL client with primary and balanced connection routing",
5
- "license": "Apache-2.0",
3
+ "version": "0.1.1",
4
+ "description": "Generic MySQL and MariaDB client with primary, balanced, and resilient routing",
5
+ "keywords": [
6
+ "eliware",
7
+ "elera",
8
+ "mysql",
9
+ "mariadb",
10
+ "sql",
11
+ "database",
12
+ "routing",
13
+ "failover",
14
+ "websocket",
15
+ "connection-pool"
16
+ ],
6
17
  "repository": {
7
18
  "type": "git",
8
19
  "url": "https://github.com/eliware/elera-lib.git"
9
20
  },
21
+ "homepage": "https://github.com/eliware/elera-lib#readme",
22
+ "bugs": {
23
+ "url": "https://github.com/eliware/elera-lib/issues"
24
+ },
25
+ "license": "Apache-2.0",
10
26
  "type": "module",
11
27
  "engines": {
12
28
  "node": ">=26"
@@ -48,4 +64,4 @@
48
64
  "publishConfig": {
49
65
  "access": "public"
50
66
  }
51
- }
67
+ }
@@ -28,7 +28,8 @@ export async function createDb({ primary, balanced, bundle, credentialProvider,
28
28
  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(); } },
29
29
  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 }; },
30
30
  async refresh(nextBundle) { const candidate = validateBundle(nextBundle); if (bundleExpired(candidate, now())) throw new Error('routing bundle is expired'); const previous = [primaryPool, balancedPool]; const credentials = candidate.credentials ?? { username: primaryConfig.user, password: primaryConfig.password }; primaryConfig = validateProfile({ ...primaryConfig, host: candidate.routes.primary[0]?.host, port: candidate.routes.primary[0]?.port, user: credentials.username, password: credentials.password, database: candidate.database }, 'primary'); balancedConfig = candidate.routes.balanced?.[0] ? validateProfile({ ...primaryConfig, host: candidate.routes.balanced[0].host, port: candidate.routes.balanced[0].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()) }; },
31
- async attachRoutingStream(stream) { if (!stream?.connect) throw new TypeError('routing stream is required'); stream.setOnUpdate?.(async (event) => { if (event.type === 'routing.update' && event.routes?.primary?.length) await client.refresh({ ...activeBundle, database: event.database ?? activeBundle?.database ?? primaryConfig.database, credentials: event.credentials ?? activeBundle?.credentials, routes: event.routes, bundleVersion: event.bundleVersion ?? activeBundle?.bundleVersion, expiresAt: activeBundle?.expiresAt ?? new Date(now() + 60000).toISOString() }); for (const host of event.type === 'routing.drain' ? [event.node] : event.type === 'routing.recovery' ? [event.node] : []) { primaryPool.setAvailability(host, event.type === 'routing.recovery'); balancedPool?.setAvailability(host, event.type === 'routing.recovery'); } }); await stream.connect(); return () => stream.close?.(); },
31
+ async attachRoutingStream(stream) { if (!stream?.connect) throw new TypeError('routing stream is required'); stream.setOnUpdate?.(async (event) => { if (event.type === 'routing.update' && event.routes?.primary?.length) await client.refresh({ ...activeBundle, database: event.database ?? activeBundle?.database ?? primaryConfig.database, credentials: event.credentials ?? activeBundle?.credentials, routes: event.routes, bundleVersion: event.bundleVersion ?? activeBundle?.bundleVersion, expiresAt: activeBundle?.expiresAt ?? new Date(now() + 60000).toISOString() }); for (const host of event.type === 'routing.drain' ? [event.node] : event.type === 'routing.recovery' ? [event.node] : []) client.setNodeAvailability('primary', host, event.type === 'routing.recovery'); }); await stream.connect(); return () => stream.close?.(); },
32
+ setNodeAvailability(route, host, available) { const pool = route === 'balanced' ? balancedPool : primaryPool; pool?.setAvailability(host, available); },
32
33
  bundle: () => activeBundle,
33
34
  async close() { await Promise.all([primaryPool.close(), balancedPool?.close()]); },
34
35
  classify: classifyQuery,
@@ -1,7 +1,7 @@
1
1
  import { createDb } from './create-db.mjs';
2
2
 
3
3
  export async function createDbFromEnvironment({ env = process.env, ...options } = {}) {
4
- const primary = { host: env.MYSQL_PRIMARY_HOST ?? env.MYSQL_HOST, port: env.MYSQL_PRIMARY_PORT ?? env.MYSQL_PORT, user: env.MYSQL_USER, password: env.MYSQL_PASSWORD, database: env.MYSQL_DATABASE, options: { connectTimeout: env.MYSQL_CONNECT_TIMEOUT, acquireTimeout: env.MYSQL_ACQUIRE_TIMEOUT, connectionLimit: env.MYSQL_CONNECTION_LIMIT, queueLimit: env.MYSQL_QUEUE_LIMIT, ssl: env.MYSQL_SSL } };
4
+ const primary = { host: env.MYSQL_PRIMARY_HOST ?? env.MYSQL_HOST ?? 'localhost', port: env.MYSQL_PRIMARY_PORT ?? env.MYSQL_PORT, user: env.MYSQL_USER, password: env.MYSQL_PASSWORD, database: env.MYSQL_DATABASE, options: { socket: env.MYSQL_SOCKET, connectTimeout: env.MYSQL_CONNECT_TIMEOUT, acquireTimeout: env.MYSQL_ACQUIRE_TIMEOUT, connectionLimit: env.MYSQL_CONNECTION_LIMIT, queueLimit: env.MYSQL_QUEUE_LIMIT, ssl: env.MYSQL_SSL } };
5
5
  const balanced = env.MYSQL_BALANCED_PORT ? { host: env.MYSQL_BALANCED_HOST ?? primary.host, port: env.MYSQL_BALANCED_PORT } : undefined;
6
6
  return createDb({ ...options, primary, balanced });
7
7
  }
package/src/index.d.ts CHANGED
@@ -6,7 +6,7 @@ export interface ConnectionProfile {
6
6
  user?: string;
7
7
  password?: string;
8
8
  database: string;
9
- options?: Partial<PoolOptions>;
9
+ options?: Partial<PoolOptions> & { socket?: string };
10
10
  }
11
11
 
12
12
  export interface RoutingNode { host: string; port: number | string; weight?: number; }
@@ -35,14 +35,15 @@ export interface DbClient {
35
35
  bundle(): RoutingBundle | undefined;
36
36
  classify(sql: string): 'primary' | 'balanced';
37
37
  attachRoutingStream(stream: RoutingStream): Promise<() => void>;
38
+ setNodeAvailability(route: 'primary' | 'balanced', host: string, available: boolean): void;
38
39
  config: { primary: ConnectionProfile; balanced?: ConnectionProfile };
39
40
  }
40
41
 
41
42
  export interface RoutingStream {
42
43
  connect(): Promise<void>;
43
- setOnUpdate(handler: (event: unknown) => void): void;
44
+ setOnUpdate(handler: (event: unknown) => void | Promise<void>): void;
44
45
  close(): void;
45
- state(): { connected: boolean; expectedVersion: number };
46
+ state(): { connected: boolean; mode: 'websocket' | 'rest' | 'disconnected'; expectedVersion: number };
46
47
  }
47
48
 
48
49
  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; now?: () => number }): Promise<DbClient>;
@@ -2,11 +2,12 @@ import { asSqlError } from '../errors.mjs';
2
2
  const connectionFailure = (error) => asSqlError(error).retryable;
3
3
  export function createNodePool({ profile, mysqlLib, log, now = () => Date.now(), quarantineMs = 5000 }) {
4
4
  const { acquireTimeout: _acquireTimeout, ...driverOptions } = profile.options ?? {};
5
+ if (driverOptions.socket === undefined) delete driverOptions.socket;
5
6
  const pool = mysqlLib.createPool({ host: profile.host, port: profile.port, user: profile.user, password: profile.password, database: profile.database, waitForConnections: true, ...driverOptions });
6
7
  const sessionStatements = profile.options?.sessionStatements ?? [];
7
8
  const withConnection = async (operation, sql, values) => { const connection = await pool.getConnection(); try { for (const statement of sessionStatements) await connection.query(statement); return operation(connection, sql, values); } finally { connection.release(); } };
8
- let failures = 0; let unavailableUntil = 0;
9
- return { host: profile.host, port: profile.port, weight: Number(profile.weight ?? 100), get available() { return now() >= unavailableUntil; }, set available(value) { unavailableUntil = value ? 0 : now() + quarantineMs; }, get failures() { return failures; },
9
+ let failures = 0; let unavailableUntil = 0; let forcedUnavailable = false;
10
+ return { host: profile.host, port: profile.port, weight: Number(profile.weight ?? 100), get available() { return !forcedUnavailable && now() >= unavailableUntil; }, set available(value) { forcedUnavailable = !value; if (value) unavailableUntil = 0; else unavailableUntil = now() + quarantineMs; }, get failures() { return failures; },
10
11
  async query(sql, values) { try { const result = sessionStatements.length ? await withConnection((connection, statement, params) => connection.query(statement, params), sql, values) : await pool.query(sql, values); failures = 0; return result; } catch (error) { if (connectionFailure(error)) { failures += 1; unavailableUntil = now() + quarantineMs; log?.warn?.('SQL node quarantined', { host: profile.host, port: profile.port, error: error.message }); } throw asSqlError(error); } },
11
12
  async execute(sql, values) { try { const result = sessionStatements.length ? await withConnection((connection, statement, params) => connection.execute(statement, params), sql, values) : await pool.execute(sql, values); failures = 0; return result; } catch (error) { if (connectionFailure(error)) { failures += 1; unavailableUntil = now() + quarantineMs; } throw asSqlError(error); } },
12
13
  async getConnection() { try { return await pool.getConnection(); } catch (error) { if (connectionFailure(error)) unavailableUntil = now() + quarantineMs; throw asSqlError(error); } },
@@ -2,16 +2,16 @@ import { log as defaultLog } from '@eliware/common';
2
2
 
3
3
  export function createRoutingStream({ endpoint, token, application = 'default', fetchBundle, WebSocketImpl = globalThis.WebSocket, onUpdate, onError, reconnectMs = 1000, maxReconnectMs = 30000, now = () => Date.now() } = {}) {
4
4
  if (!endpoint || typeof fetchBundle !== 'function') throw new TypeError('endpoint and fetchBundle are required');
5
- let socket; let closed = false; let timer; let expectedVersion = 0; let delay = reconnectMs; let updateHandler = onUpdate;
5
+ let socket; let closed = false; let timer; let expectedVersion = 0; let delay = reconnectMs; let updateHandler = onUpdate; let mode = 'disconnected';
6
6
  const log = arguments[0]?.log ?? defaultLog;
7
7
  const streamUrl = () => `${endpoint.replace(/^http/i, 'ws').replace(/\/$/, '')}/api/v1/routing/stream?application=${encodeURIComponent(application)}&token=${encodeURIComponent(token ?? '')}`;
8
- async function fallback() { try { const bundle = await fetchBundle(application); onUpdate?.({ type: 'routing.resync', version: expectedVersion, bundle, receivedAt: now() }); } catch (error) { onError?.(error); log.warn?.('Routing REST fallback failed', { error }); } }
8
+ async function fallback() { try { const bundle = await fetchBundle(application); if (closed) return; mode = 'rest'; onUpdate?.({ 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
9
  function schedule() { if (closed || timer) return; timer = setTimeout(() => { timer = undefined; void connect(); }, delay); delay = Math.min(maxReconnectMs, delay * 2); }
10
10
  async function connect() {
11
11
  if (closed || typeof WebSocketImpl !== 'function') { await fallback(); schedule(); return; }
12
12
  try {
13
13
  socket = new WebSocketImpl(streamUrl());
14
- socket.onopen = () => { delay = reconnectMs; };
14
+ socket.onopen = () => { mode = 'websocket'; delay = reconnectMs; };
15
15
  socket.onmessage = async ({ data }) => {
16
16
  try {
17
17
  const event = JSON.parse(data); const version = Number(event.version ?? 0);
@@ -20,8 +20,8 @@ export function createRoutingStream({ endpoint, token, application = 'default',
20
20
  } catch (error) { onError?.(error); }
21
21
  };
22
22
  socket.onerror = (error) => { onError?.(error); };
23
- socket.onclose = () => { socket = undefined; void fallback(); schedule(); };
24
- } catch (error) { onError?.(error); await fallback(); schedule(); }
23
+ socket.onclose = () => { socket = undefined; mode = 'disconnected'; if (!closed) { void fallback(); schedule(); } };
24
+ } catch (error) { mode = 'disconnected'; onError?.(error); await fallback(); schedule(); }
25
25
  }
26
- return { connect, setOnUpdate: (handler) => { updateHandler = handler; }, close: () => { closed = true; clearTimeout(timer); socket?.close?.(); }, state: () => ({ connected: socket?.readyState === 1, expectedVersion }) };
26
+ return { connect, setOnUpdate: (handler) => { updateHandler = handler; }, close: () => { closed = true; mode = 'disconnected'; clearTimeout(timer); socket?.close?.(); }, state: () => ({ connected: socket?.readyState === 1, mode, expectedVersion }) };
27
27
  }