@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 +9 -5
- package/RELEASE_NOTES.md +30 -0
- package/package.json +20 -4
- package/src/client/create-db.mjs +2 -1
- package/src/client/environment.mjs +1 -1
- package/src/index.d.ts +4 -3
- package/src/pools/node-pool.mjs +3 -2
- package/src/routing/stream-client.mjs +6 -6
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.
|
|
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`.
|
|
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.
|
|
38
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
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
|
+
}
|
package/src/client/create-db.mjs
CHANGED
|
@@ -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] : [])
|
|
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>;
|
package/src/pools/node-pool.mjs
CHANGED
|
@@ -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) {
|
|
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
|
}
|