@eliware/elera-lib 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,15 @@
1
+ Eliware-authored code is licensed under the Apache License, Version 2.0.
2
+ Third-party components retain their respective licenses; see
3
+ THIRD-PARTY-NOTICES.md.
4
+
5
+ Copyright 2026 Eliware
6
+
7
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not
8
+ use this file except in compliance with the License. You may obtain a copy at:
9
+
10
+ https://www.apache.org/licenses/LICENSE-2.0
11
+
12
+ Unless required by applicable law or agreed to in writing, software
13
+ distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
14
+ WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
15
+ License for the specific language governing permissions and limitations.
package/README.md ADDED
@@ -0,0 +1,74 @@
1
+ # @eliware/elera-lib
2
+
3
+ The alternative SQL client for Eliware applications. It provides generic
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
6
+ existing package is intentionally unchanged.
7
+
8
+ `primary` is the preferred connection path. `balanced` is an optional alternate
9
+ path. Both may accept writes; automatic routing sends only conservative,
10
+ single-statement read queries to `balanced`. Transactions always use `primary`.
11
+
12
+ ```js
13
+ import { createDbFromEnvironment } from '@eliware/elera-lib';
14
+ const db = await createDbFromEnvironment();
15
+ await db.query('SELECT 1');
16
+ await db.close();
17
+ ```
18
+
19
+ Environment variables: `MYSQL_PRIMARY_HOST` (or `MYSQL_HOST`),
20
+ `MYSQL_PRIMARY_PORT` (or `MYSQL_PORT`), optional `MYSQL_BALANCED_HOST`,
21
+ optional `MYSQL_BALANCED_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, and
22
+ `MYSQL_DATABASE`. Pool settings may be supplied with `MYSQL_CONNECT_TIMEOUT`,
23
+ `MYSQL_ACQUIRE_TIMEOUT`, `MYSQL_CONNECTION_LIMIT`, `MYSQL_QUEUE_LIMIT`, and
24
+ `MYSQL_SSL`. Configure primary and balanced routes explicitly; applications
25
+ should not rely on ambiguous single-endpoint aliases.
26
+
27
+ Routing bundles passed to `createDbFromBundle` use the normalized shape
28
+ `routes.primary` and `routes.balanced`, each containing ordered `{ host, port,
29
+ weight }` nodes. A bundle also carries `database`, `identity`, optional
30
+ `credentials`, and `expiresAt`; `validateBundle` rejects expired or malformed
31
+ route data. The checked-in contract fixture documents the supervisor-facing
32
+ wire representation separately.
33
+
34
+ The implementation accepts optional routing bundles and injected credential
35
+ providers, maintains bounded pools per route, supports ordered writer/reader
36
+ 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
+ library does not know about supervisors, Elera, HAProxy, GitOps, backups, or
40
+ CLI commands. Applications provide those integrations through ordinary
41
+ configuration and callbacks.
42
+
43
+ The public client intentionally exposes SQL operations, health, routing,
44
+ lifecycle, and optional routing-event synchronization methods. REST and
45
+ WebSocket transports are adapters, not supervisor or CLI policy. Underlying
46
+ `mysql2` pools and driver objects remain internal implementation details.
47
+
48
+ For maintenance workflows, `createQuiesceController` provides a generic
49
+ connection-admission drain and `createSqlVerifier` provides generic connectivity,
50
+ schema, account, and grant checks. Neither API transports or orchestrates dump
51
+ contents.
52
+
53
+ `createMaterializer` supports bounded plaintext use for a caller-provided
54
+ operation. It creates a mode-restricted temporary file and removes its entire
55
+ temporary directory in a `finally` block; this limits lifetime and cleanup but
56
+ does not hide plaintext from the caller. The library does not persist secrets,
57
+ age keys, or supervisor-specific artifact metadata.
58
+
59
+ The package exports the SQL client and environment/bundle factories, query
60
+ classification and route selection, routing-bundle validation, generic REST
61
+ and WebSocket routing adapters, SQL administration and verification helpers,
62
+ and lifecycle helpers for quiescing and temporary materialization. These
63
+ helpers remain policy-neutral and do not provision users, manage clusters, or
64
+ perform backup/restore orchestration.
65
+
66
+ ## Development
67
+
68
+ ```bash
69
+ npm ci
70
+ npm test
71
+ npm run lint
72
+ npm run typecheck
73
+ npm run pack
74
+ ```
@@ -0,0 +1,71 @@
1
+ # Release notes
2
+
3
+ ## 0.1.0 — Baseline release
4
+
5
+ `@eliware/elera-lib` is a generic MariaDB/MySQL client library for applications
6
+ that need multiple connection routes without embedding supervisor, HAProxy,
7
+ GitOps, backup, or CLI policy.
8
+
9
+ ### SQL client and pooling
10
+
11
+ - Creates clients from explicit connection profiles or environment variables.
12
+ - Creates clients from validated routing bundles.
13
+ - Maintains independent bounded pools for primary and balanced routes.
14
+ - Keeps the underlying MySQL driver and pool implementation private.
15
+ - Supports `query`, `execute`, transactions, health checks, and graceful close.
16
+ - Pins transactions to the primary route.
17
+ - Preserves credentials while applying refreshed routing bundles.
18
+
19
+ ### Routing and failover
20
+
21
+ - Classifies conservative, single-statement reads for balanced routing.
22
+ - Sends writes and transactions to the primary route by default.
23
+ - Supports explicit `primary`, `balanced`, and `auto` route selection.
24
+ - Selects ordered, weighted candidate nodes from a routing bundle.
25
+ - Quarantines unhealthy nodes and retries retryable connection failures.
26
+ - Validates bundle shape and expiry before use.
27
+ - Supports bundle refresh and refresh-needed checks.
28
+ - Supports node recovery and re-admission after quarantine.
29
+
30
+ ### Routing-event transport
31
+
32
+ - Provides a generic WebSocket routing-stream adapter.
33
+ - Accepts versioned routing updates, drain events, and recovery events.
34
+ - Falls back to a caller-provided REST bundle fetch when WebSockets are
35
+ unavailable or fail.
36
+ - Sends no SQL statements, credentials, dumps, or application data over the
37
+ event stream.
38
+ - Keeps supervisor-specific protocol decisions outside the library.
39
+
40
+ ### Lifecycle and maintenance
41
+
42
+ - Provides connection-admission quiescing for graceful drains.
43
+ - Stops new work while allowing active work to finish.
44
+ - Supports deterministic pool shutdown and cleanup.
45
+ - Provides bounded temporary-file materialization for caller-provided
46
+ operations, with mode `0600` and recursive cleanup in `finally` blocks.
47
+
48
+ ### Verification and administration
49
+
50
+ - Provides generic connectivity, schema, account, and grant verification.
51
+ - Provides SQL helpers for administrative and migration workflows.
52
+ - Exposes structured error classification through `SqlClientError`,
53
+ `classifyError`, and `asSqlError`.
54
+ - Supports injected drivers, credential providers, clocks, logging, and stream
55
+ transports for deterministic testing.
56
+
57
+ ### Security and scope
58
+
59
+ - Redacts passwords and private TLS key material from profile representations.
60
+ - Does not persist credentials, bearer tokens, age keys, or plaintext artifacts.
61
+ - Does not expose supervisor-specific endpoints or CLI commands as library
62
+ policy.
63
+ - Requires applications to provide their own credential and routing adapters.
64
+
65
+ ### Compatibility and validation
66
+
67
+ - ESM package targeting Node.js 26 or newer.
68
+ - TypeScript declarations are included with the package.
69
+ - Existing `@eliware/mysql` is not modified or required.
70
+ - Strict test coverage is maintained at 100% statements, branches, functions,
71
+ and lines with zero lint warnings.
@@ -0,0 +1,5 @@
1
+ # Third-party notices
2
+
3
+ Eliware-authored code is Apache-2.0 licensed. `mysql2` and transitive npm
4
+ dependencies retain their respective licenses as recorded in package metadata.
5
+ Review the complete dependency tree before redistribution.
package/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
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",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/eliware/elera-lib.git"
9
+ },
10
+ "type": "module",
11
+ "engines": {
12
+ "node": ">=26"
13
+ },
14
+ "exports": {
15
+ ".": {
16
+ "types": "./src/index.d.ts",
17
+ "import": "./src/index.mjs"
18
+ }
19
+ },
20
+ "files": [
21
+ "src",
22
+ "README.md",
23
+ "RELEASE_NOTES.md",
24
+ "LICENSE",
25
+ "THIRD-PARTY-NOTICES.md"
26
+ ],
27
+ "scripts": {
28
+ "test": "eliware-test",
29
+ "integration": "node scripts/integration.mjs",
30
+ "lint": "eliware-test --lint",
31
+ "check": "node --check src/index.mjs",
32
+ "contracts": "node scripts/verify-contracts.mjs",
33
+ "typecheck": "tsc --project tsconfig.json",
34
+ "pack": "npm pack --dry-run",
35
+ "audit": "npm audit --omit=dev --audit-level=moderate"
36
+ },
37
+ "dependencies": {
38
+ "@eliware/common": "^2.0.0",
39
+ "mysql2": "^3.24.2"
40
+ },
41
+ "devDependencies": {
42
+ "@eliware/test": "^2.0.0",
43
+ "@types/node": "^26.4.0",
44
+ "ajv": "^8.20.0",
45
+ "ajv-formats": "^3.0.1",
46
+ "typescript": "^7.0.2"
47
+ },
48
+ "publishConfig": {
49
+ "access": "public"
50
+ }
51
+ }
@@ -0,0 +1,27 @@
1
+ export function createMigrationRunner({ query, migrations = [] }) {
2
+ if (typeof query !== 'function') throw new TypeError('query function is required');
3
+ if (!Array.isArray(migrations)) throw new TypeError('migrations must be an array');
4
+ const ordered = [...migrations].sort((a, b) => a.version - b.version);
5
+ return {
6
+ async status() {
7
+ const [rows] = await query('SELECT version, name FROM schema_migrations ORDER BY version');
8
+ return { applied: rows };
9
+ },
10
+ async migrate() {
11
+ await query('CREATE TABLE IF NOT EXISTS schema_migrations (version INT PRIMARY KEY, name VARCHAR(255) NOT NULL, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)');
12
+ const [rows] = await query('SELECT version FROM schema_migrations ORDER BY version');
13
+ const applied = new Set(rows.map((row) => Number(row.version)));
14
+ for (const migration of ordered) {
15
+ if (!Number.isInteger(migration.version) || !migration.name || !Array.isArray(migration.statements)) throw new TypeError('invalid migration');
16
+ if (applied.has(migration.version)) continue;
17
+ await query('START TRANSACTION');
18
+ try {
19
+ for (const statement of migration.statements) { if (typeof statement !== 'string' || !statement.trim()) throw new TypeError('migration statements must be non-empty strings'); await query(statement); }
20
+ await query('INSERT INTO schema_migrations (version, name) VALUES (?, ?)', [migration.version, migration.name]);
21
+ await query('COMMIT');
22
+ } catch (error) { try { await query('ROLLBACK'); } catch {} throw error; }
23
+ }
24
+ return this.status();
25
+ }
26
+ };
27
+ }
@@ -0,0 +1,7 @@
1
+ export function createAdminSql({ query }) {
2
+ if (typeof query !== 'function') throw new TypeError('query function is required');
3
+ return {
4
+ async transaction(work) { await query('START TRANSACTION'); try { const result = await work({ query }); await query('COMMIT'); return result; } catch (error) { try { await query('ROLLBACK'); } catch {} throw error; } },
5
+ async migration(statements = []) { return this.transaction(async ({ query: run }) => { for (const statement of statements) { if (typeof statement !== 'string' || !statement.trim()) throw new TypeError('migration statements must be non-empty strings'); await run(statement); } }); }
6
+ };
7
+ }
package/src/bundle.mjs ADDED
@@ -0,0 +1,17 @@
1
+ const routes = ['primary', 'balanced'];
2
+
3
+ export function validateBundle(bundle) {
4
+ if (!bundle || typeof bundle !== 'object') throw new TypeError('routing bundle is required');
5
+ if (!bundle.expiresAt || Number.isNaN(Date.parse(bundle.expiresAt))) throw new TypeError('routing bundle expiresAt is required');
6
+ for (const route of routes) {
7
+ if (bundle.routes?.[route] !== undefined && !Array.isArray(bundle.routes[route])) throw new TypeError(`bundle.routes.${route} must be an array`);
8
+ for (const node of bundle.routes?.[route] ?? []) {
9
+ if (!node.host || !Number.isInteger(Number(node.port)) || Number(node.port) < 1 || Number(node.port) > 65535) throw new TypeError(`invalid ${route} bundle node`);
10
+ if (node.weight !== undefined && (!Number.isFinite(Number(node.weight)) || Number(node.weight) < 0)) throw new TypeError(`invalid ${route} bundle weight`);
11
+ }
12
+ }
13
+ return bundle;
14
+ }
15
+
16
+ export function bundleExpired(bundle, now = Date.now()) { return Date.parse(bundle.expiresAt) <= now; }
17
+ export function bundleNeedsRefresh(bundle, now = Date.now()) { return bundle.refreshAfter ? Date.parse(bundle.refreshAfter) <= now : bundleExpired(bundle, now); }
@@ -0,0 +1,39 @@
1
+ import { log as defaultLog } from '@eliware/common';
2
+ import * as mysql from 'mysql2/promise';
3
+ import { validateProfile, redactedProfile } from '../config.mjs';
4
+ import { asSqlError } from '../errors.mjs';
5
+ import { resolveCredentials, credentialContext } from '../credential-provider.mjs';
6
+ import { validateBundle, bundleExpired, bundleNeedsRefresh } from '../bundle.mjs';
7
+ import { createRouteFactory } from './route-factory.mjs';
8
+ import { classifyQuery, routeFor } from '../routing.mjs';
9
+
10
+ export async function createDb({ primary, balanced, bundle, credentialProvider, mysqlLib = mysql, log = defaultLog, routing = 'auto', identity, quarantineMs = 5000, now = () => Date.now() } = {}) {
11
+ if (!primary || typeof primary !== 'object') throw new TypeError('primary connection profile is required');
12
+ const credentials = await resolveCredentials(credentialProvider, credentialContext(primary, { identity }));
13
+ let primaryConfig = validateProfile({ ...primary, ...credentials }, 'primary');
14
+ if (!primaryConfig.user || typeof primaryConfig.password !== 'string') throw new TypeError('primary.user and primary.password are required');
15
+ let balancedConfig = balanced ? validateProfile({ ...primaryConfig, ...balanced, ...credentials }, 'balanced') : undefined;
16
+ if (credentials.user || credentials.password) {
17
+ primaryConfig = validateProfile({ ...primaryConfig, ...credentials }, 'primary');
18
+ if (balancedConfig) balancedConfig = validateProfile({ ...balancedConfig, ...credentials }, 'balanced');
19
+ }
20
+ let activeBundle = bundle ? validateBundle(bundle) : undefined;
21
+ const makeRoute = (route, fallback) => createRouteFactory({ bundle: activeBundle, now, mysqlLib, log, quarantineMs })(route, fallback);
22
+ let primaryPool = makeRoute('primary', primaryConfig);
23
+ let balancedPool = balancedConfig || activeBundle?.routes?.balanced ? makeRoute('balanced', balancedConfig ?? primaryConfig) : null;
24
+ const choose = (sql, options = {}) => options.connection ?? (routeFor(sql, options.route ?? routing) === 'balanced' && balancedPool ? balancedPool : primaryPool);
25
+ const client = {
26
+ async query(sql, values, options) { const selected = choose(sql, options); try { return await selected.query(sql, values); } catch (error) { if (error.retryable && routeFor(sql, options?.route ?? routing) === 'balanced') return balancedPool.query(sql, values); throw error; } },
27
+ async execute(sql, values, options) { return choose(sql, options).execute(sql, values); },
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
+ 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
+ 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?.(); },
32
+ bundle: () => activeBundle,
33
+ async close() { await Promise.all([primaryPool.close(), balancedPool?.close()]); },
34
+ classify: classifyQuery,
35
+ config: { primary: redactedProfile(primaryConfig), balanced: balancedConfig && redactedProfile(balancedConfig) }
36
+ };
37
+ log.debug?.('SQL client created', { balanced: Boolean(balancedPool), routing });
38
+ return client;
39
+ }
@@ -0,0 +1,7 @@
1
+ import { createDb } from './create-db.mjs';
2
+
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 } };
5
+ const balanced = env.MYSQL_BALANCED_PORT ? { host: env.MYSQL_BALANCED_HOST ?? primary.host, port: env.MYSQL_BALANCED_PORT } : undefined;
6
+ return createDb({ ...options, primary, balanced });
7
+ }
@@ -0,0 +1,14 @@
1
+ import { validateBundle } from '../bundle.mjs';
2
+
3
+ export function profilesFromBundle(bundle) {
4
+ const valid = validateBundle(bundle);
5
+ const credentials = valid.credentials ?? {};
6
+ const base = { host: valid.routes.primary[0]?.host, port: valid.routes.primary[0]?.port, user: credentials.username, password: credentials.password, database: valid.database };
7
+ return { primary: base, balanced: valid.routes.balanced?.[0] ? { ...base, host: valid.routes.balanced[0].host, port: valid.routes.balanced[0].port } : undefined };
8
+ }
9
+
10
+ export async function createDbFromBundle({ bundle, createClient, ...options } = {}) {
11
+ const profiles = profilesFromBundle(bundle);
12
+ const factory = createClient ?? (await import('./create-db.mjs')).createDb;
13
+ return factory({ ...options, ...profiles, bundle, identity: bundle.identity });
14
+ }
@@ -0,0 +1,18 @@
1
+ import { bundleExpired } from '../bundle.mjs';
2
+ import { createNodePool, createRoutePool } from '../pools.mjs';
3
+
4
+ const bundleProfiles = (bundle, route, base) => {
5
+ const routes = bundle.routes?.[route] ?? [];
6
+ return routes.map((node) => ({ ...base, host: node.host, port: node.port, weight: node.weight }));
7
+ };
8
+
9
+ const routeProfiles = (bundle, route, fallback, now) => {
10
+ if (!bundle) return [fallback];
11
+ if (bundleExpired(bundle, now())) return [fallback];
12
+ const profiles = bundleProfiles(bundle, route, fallback);
13
+ return profiles.length ? profiles : [fallback];
14
+ };
15
+
16
+ export function createRouteFactory({ bundle, now, mysqlLib, log, quarantineMs }) {
17
+ return (route, fallback) => createRoutePool(routeProfiles(bundle, route, fallback, now).map((profile) => createNodePool({ profile, mysqlLib, log, now, quarantineMs })));
18
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,31 @@
1
+ const integer = (value, name, { min = 0, max = 65535 } = {}) => {
2
+ const number = Number(value);
3
+ if (!Number.isInteger(number) || number < min || number > max) throw new TypeError(`${name} must be an integer between ${min} and ${max}`);
4
+ return number;
5
+ };
6
+
7
+ export function validateProfile(profile, name = 'connection') {
8
+ if (!profile || typeof profile !== 'object') throw new TypeError(`${name} profile is required`);
9
+ if (!profile.host || typeof profile.host !== 'string') throw new TypeError(`${name}.host is required`);
10
+ if (profile.user !== undefined && typeof profile.user !== 'string') throw new TypeError(`${name}.user must be a string`);
11
+ if (profile.password !== undefined && typeof profile.password !== 'string') throw new TypeError(`${name}.password must be a string`);
12
+ if (!profile.database || typeof profile.database !== 'string') throw new TypeError(`${name}.database is required`);
13
+ const port = integer(profile.port ?? 3306, `${name}.port`, { min: 1 });
14
+ const options = profile.options ?? {};
15
+ const connectionLimit = integer(options.connectionLimit ?? 10, `${name}.options.connectionLimit`, { min: 1, max: 1000 });
16
+ const queueLimit = integer(options.queueLimit ?? 0, `${name}.options.queueLimit`, { max: 1000000 });
17
+ const connectTimeout = integer(options.connectTimeout ?? 10000, `${name}.options.connectTimeout`, { max: 3600000 });
18
+ const acquireTimeout = integer(options.acquireTimeout ?? 10000, `${name}.options.acquireTimeout`, { max: 3600000 });
19
+ if (options.ssl !== undefined && typeof options.ssl !== 'object' && typeof options.ssl !== 'string') throw new TypeError(`${name}.options.ssl must be an object or string`);
20
+ return { ...profile, port, options: { ...options, connectionLimit, queueLimit, connectTimeout, acquireTimeout } };
21
+ }
22
+
23
+ export function redactedProfile(profile) {
24
+ const { password: _password, options = {}, ...safe } = profile;
25
+ const safeOptions = { ...options };
26
+ if (safeOptions.ssl && typeof safeOptions.ssl === 'object') {
27
+ safeOptions.ssl = { ...safeOptions.ssl };
28
+ for (const key of ['key', 'privateKey', 'passphrase']) delete safeOptions.ssl[key];
29
+ }
30
+ return { ...safe, options: safeOptions };
31
+ }
@@ -0,0 +1,12 @@
1
+ export async function resolveCredentials(provider, context) {
2
+ if (provider === undefined) return {};
3
+ if (typeof provider !== 'function') throw new TypeError('credentialProvider must be a function');
4
+ const result = await provider(context);
5
+ if (!result || typeof result !== 'object') throw new TypeError('credentialProvider must return an object');
6
+ if (typeof result.user !== 'string' || typeof result.password !== 'string') throw new TypeError('credentialProvider must return user and password');
7
+ return result;
8
+ }
9
+
10
+ export function credentialContext(primary, options) {
11
+ return { database: primary.database, identity: options.identity ?? null, route: options.route ?? 'primary' };
12
+ }
package/src/errors.mjs ADDED
@@ -0,0 +1,18 @@
1
+ export class SqlClientError extends Error {
2
+ constructor(message, { code = 'SQL_CLIENT_ERROR', retryable = false, cause } = {}) {
3
+ super(message, { cause }); this.name = 'SqlClientError'; this.code = code; this.retryable = retryable;
4
+ }
5
+ }
6
+
7
+ export function classifyError(error) {
8
+ const code = error?.code;
9
+ if (['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'PROTOCOL_CONNECTION_LOST', 'PROTOCOL_ENQUEUE_AFTER_FATAL_ERROR'].includes(code)) return { code: 'CONNECTION_ERROR', retryable: true };
10
+ if (['ER_ACCESS_DENIED_ERROR', 'ER_DBACCESS_DENIED_ERROR'].includes(code)) return { code: 'AUTHENTICATION_ERROR', retryable: false };
11
+ return { code: code ?? 'SQL_ERROR', retryable: false };
12
+ }
13
+
14
+ export function asSqlError(error, message = 'SQL operation failed') {
15
+ if (error instanceof SqlClientError) return error;
16
+ const classification = classifyError(error);
17
+ return new SqlClientError(message, { ...classification, cause: error });
18
+ }
package/src/index.d.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { PoolOptions } from 'mysql2/promise';
2
+
3
+ export interface ConnectionProfile {
4
+ host: string;
5
+ port?: number | string;
6
+ user?: string;
7
+ password?: string;
8
+ database: string;
9
+ options?: Partial<PoolOptions>;
10
+ }
11
+
12
+ export interface RoutingNode { host: string; port: number | string; weight?: number; }
13
+ export interface RoutingBundle {
14
+ apiVersion?: string;
15
+ database?: string;
16
+ identity?: string;
17
+ credentials?: { username?: string; password?: string };
18
+ bundleVersion?: number | string;
19
+ expiresAt: string;
20
+ refreshAfter?: string;
21
+ routes: { primary?: RoutingNode[]; balanced?: RoutingNode[] };
22
+ }
23
+ export interface CredentialProviderResult { user: string; password: string; }
24
+ export type CredentialProvider = (context: { database: string; identity: string | null; route: string }) => Promise<CredentialProviderResult> | CredentialProviderResult;
25
+ export type QueryFunction = (sql: string, values?: unknown) => Promise<any>;
26
+ export interface DbOptions { route?: 'auto' | 'primary' | 'balanced'; connection?: unknown; }
27
+
28
+ export interface DbClient {
29
+ query(sql: string, values?: unknown, options?: DbOptions): Promise<unknown>;
30
+ execute(sql: string, values?: unknown, options?: DbOptions): Promise<unknown>;
31
+ transaction<T>(callback: (transaction: Pick<DbClient, 'query' | 'execute'>) => Promise<T>): Promise<T>;
32
+ health(route?: 'primary' | 'balanced'): Promise<{ ok: boolean; route: string; latencyMs: number }>;
33
+ close(): Promise<void>;
34
+ refresh(bundle: RoutingBundle): Promise<{ bundleVersion: number | string | null; refreshRequired: boolean }>;
35
+ bundle(): RoutingBundle | undefined;
36
+ classify(sql: string): 'primary' | 'balanced';
37
+ attachRoutingStream(stream: RoutingStream): Promise<() => void>;
38
+ config: { primary: ConnectionProfile; balanced?: ConnectionProfile };
39
+ }
40
+
41
+ export interface RoutingStream {
42
+ connect(): Promise<void>;
43
+ setOnUpdate(handler: (event: unknown) => void): void;
44
+ close(): void;
45
+ state(): { connected: boolean; expectedVersion: number };
46
+ }
47
+
48
+ 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>;
49
+ export function createDbFromEnvironment(options?: { env?: Record<string, string | undefined>; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string }): Promise<DbClient>;
50
+ export function classifyQuery(sql: unknown): 'primary' | 'balanced';
51
+ export function routeFor(sql: unknown, requested?: 'auto' | 'primary' | 'balanced'): 'primary' | 'balanced';
52
+ export function validateProfile(profile: ConnectionProfile, name?: string): ConnectionProfile;
53
+ export function redactedProfile(profile: ConnectionProfile): ConnectionProfile;
54
+ export class SqlClientError extends Error { code?: string; retryable?: boolean; cause?: unknown; }
55
+ export function classifyError(error: unknown): { retryable: boolean; code?: string };
56
+ export function asSqlError(error: unknown): SqlClientError;
57
+ export function validateBundle(bundle: RoutingBundle): RoutingBundle;
58
+ export function bundleExpired(bundle: RoutingBundle, now?: number): boolean;
59
+ export function bundleNeedsRefresh(bundle: RoutingBundle, now?: number): boolean;
60
+ export function createAdminSql(options: { query: QueryFunction }): { transaction<T>(work: (context: { query: QueryFunction }) => Promise<T>): Promise<T>; migration(statements?: string[]): Promise<unknown> };
61
+ export function createMigrationRunner(options: { query: QueryFunction; migrations?: Array<{ version: number; name: string; statements: string[] }> }): { status(): Promise<{ applied: unknown[] }>; migrate(): Promise<unknown> };
62
+ export function selectRouteNodes(options: { bundle: RoutingBundle; route?: 'primary' | 'balanced'; now?: number }): RoutingNode[];
63
+ 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 }): RoutingStream;
64
+ 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> };
65
+ 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> };
66
+ export function createMaterializer(options?: Record<string, unknown>): unknown;
package/src/index.mjs ADDED
@@ -0,0 +1,14 @@
1
+ export { createDb } from './client/create-db.mjs';
2
+ export { createDbFromBundle, profilesFromBundle } from './client/from-bundle.mjs';
3
+ export { createDbFromEnvironment } from './client/environment.mjs';
4
+ export { classifyQuery, routeFor } from './routing.mjs';
5
+ export { validateProfile, redactedProfile } from './config.mjs';
6
+ export { SqlClientError, classifyError, asSqlError } from './errors.mjs';
7
+ export { validateBundle, bundleExpired, bundleNeedsRefresh } from './bundle.mjs';
8
+ export { createAdminSql } from './admin/sql.mjs';
9
+ export { createMigrationRunner } from './admin/migrations.mjs';
10
+ export { selectRouteNodes } from './routing/node-set.mjs';
11
+ export { createRoutingStream } from './routing/stream-client.mjs';
12
+ export { createQuiesceController } from './lifecycle/quiesce.mjs';
13
+ export { createSqlVerifier } from './verification/sql.mjs';
14
+ export { createMaterializer } from './lifecycle/materializer.mjs';
@@ -0,0 +1,16 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ export function createMaterializer({ makeTemp = mkdtemp, write = writeFile, remove = rm, id = randomUUID } = {}) {
7
+ return {
8
+ async withFile(content, operation) {
9
+ if (typeof operation !== "function") throw new TypeError("materializer operation is required");
10
+ const directory = await makeTemp(join(tmpdir(), "elera-material-") );
11
+ const path = join(directory, id());
12
+ try { await write(path, content, { mode: 0o600 }); return await operation(path); }
13
+ finally { await remove(directory, { recursive: true, force: true }); }
14
+ }
15
+ };
16
+ }
@@ -0,0 +1,20 @@
1
+ export function createQuiesceController({ close = async () => {}, onChange } = {}) {
2
+ let state = 'open';
3
+ let active = 0;
4
+ const publish = (next) => { state = next; onChange?.(state); };
5
+ const leave = () => { active = Math.max(0, active - 1); };
6
+ const enter = () => {
7
+ if (state !== 'open') throw Object.assign(new Error('SQL client is quiesced'), { code: 'QUIESCED' });
8
+ active += 1;
9
+ let left = false;
10
+ return () => { if (!left) { left = true; leave(); } };
11
+ };
12
+ const waitForIdle = async () => { while (active) await new Promise((resolve) => setTimeout(resolve, 10)); };
13
+ return {
14
+ state: () => ({ state, active }),
15
+ enter,
16
+ async begin() { publish('quiescing'); await waitForIdle(); publish('quiesced'); },
17
+ async end() { publish('open'); },
18
+ async close() { await close(); publish('closed'); }
19
+ };
20
+ }
@@ -0,0 +1,15 @@
1
+ import { asSqlError } from '../errors.mjs';
2
+ const connectionFailure = (error) => asSqlError(error).retryable;
3
+ export function createNodePool({ profile, mysqlLib, log, now = () => Date.now(), quarantineMs = 5000 }) {
4
+ const { acquireTimeout: _acquireTimeout, ...driverOptions } = profile.options ?? {};
5
+ const pool = mysqlLib.createPool({ host: profile.host, port: profile.port, user: profile.user, password: profile.password, database: profile.database, waitForConnections: true, ...driverOptions });
6
+ const sessionStatements = profile.options?.sessionStatements ?? [];
7
+ 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; },
10
+ 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
+ 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
+ async getConnection() { try { return await pool.getConnection(); } catch (error) { if (connectionFailure(error)) unavailableUntil = now() + quarantineMs; throw asSqlError(error); } },
13
+ async health() { try { await pool.query('SELECT 1'); failures = 0; unavailableUntil = 0; return { ok: true, host: profile.host, port: profile.port }; } catch (error) { if (connectionFailure(error)) { failures += 1; unavailableUntil = now() + quarantineMs; } throw asSqlError(error); } },
14
+ async close() { await pool.end(); } };
15
+ }
@@ -0,0 +1,39 @@
1
+ export function createRoutePool(nodes) {
2
+ let cursor = 0;
3
+ const candidates = () => nodes.filter((node) => node.available);
4
+ const choose = () => {
5
+ const available = candidates();
6
+ if (!available.length) throw new Error('no eligible SQL nodes available');
7
+ const total = available.reduce((sum, node) => sum + Math.max(0, node.weight), 0);
8
+ if (!total) return available[cursor++ % available.length];
9
+ let target = cursor++ % total;
10
+ let selected = available[available.length - 1];
11
+ for (const node of available) {
12
+ const weight = Math.max(0, node.weight);
13
+ if (target < weight) {
14
+ selected = node;
15
+ break;
16
+ }
17
+ target -= weight;
18
+ }
19
+ return selected;
20
+ };
21
+ const setAvailability = (host, available) => {
22
+ for (const node of nodes) if (node.host === host) node.available = available;
23
+ };
24
+ const query = (sql, values) => choose().query(sql, values);
25
+ const execute = (sql, values) => choose().execute(sql, values);
26
+ const health = async () => {
27
+ const results = [];
28
+ for (const node of nodes) {
29
+ try {
30
+ results.push(await node.health());
31
+ } catch (error) {
32
+ results.push({ ok: false, host: node.host, port: node.port, error: error.message });
33
+ }
34
+ }
35
+ return results;
36
+ };
37
+ const close = async () => Promise.all(nodes.map((node) => node.close()));
38
+ return { nodes, choose, setAvailability, query, execute, health, close };
39
+ }
package/src/pools.mjs ADDED
@@ -0,0 +1,3 @@
1
+ export { createNodePool } from './pools/node-pool.mjs';
2
+ export { createRoutePool } from './pools/route-pool.mjs';
3
+ /* istanbul ignore file -- barrel exports only. */
@@ -0,0 +1,6 @@
1
+ import { validateBundle } from '../bundle.mjs';
2
+ export function selectRouteNodes({ bundle, route = 'primary', now = Date.now() } = {}) {
3
+ validateBundle(bundle); if (Date.parse(bundle.expiresAt) <= now) throw new TypeError('routing bundle is expired');
4
+ const nodes = bundle.routes?.[route]; if (!Array.isArray(nodes) || nodes.length === 0) throw new TypeError(`routing bundle ${route} route is empty`);
5
+ return nodes.map(({ host, port, weight }) => ({ host, port: Number(port), ...(weight === undefined ? {} : { weight: Number(weight) }) }));
6
+ }
@@ -0,0 +1,27 @@
1
+ import { log as defaultLog } from '@eliware/common';
2
+
3
+ export function createRoutingStream({ endpoint, token, application = 'default', fetchBundle, WebSocketImpl = globalThis.WebSocket, onUpdate, onError, reconnectMs = 1000, maxReconnectMs = 30000, now = () => Date.now() } = {}) {
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;
6
+ const log = arguments[0]?.log ?? defaultLog;
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 }); } }
9
+ function schedule() { if (closed || timer) return; timer = setTimeout(() => { timer = undefined; void connect(); }, delay); delay = Math.min(maxReconnectMs, delay * 2); }
10
+ async function connect() {
11
+ if (closed || typeof WebSocketImpl !== 'function') { await fallback(); schedule(); return; }
12
+ try {
13
+ socket = new WebSocketImpl(streamUrl());
14
+ socket.onopen = () => { delay = reconnectMs; };
15
+ socket.onmessage = async ({ data }) => {
16
+ try {
17
+ const event = JSON.parse(data); const version = Number(event.version ?? 0);
18
+ if (expectedVersion && version > expectedVersion + 1) await fallback();
19
+ expectedVersion = Math.max(expectedVersion, version); updateHandler?.(event);
20
+ } catch (error) { onError?.(error); }
21
+ };
22
+ socket.onerror = (error) => { onError?.(error); };
23
+ socket.onclose = () => { socket = undefined; void fallback(); schedule(); };
24
+ } catch (error) { onError?.(error); await fallback(); schedule(); }
25
+ }
26
+ return { connect, setOnUpdate: (handler) => { updateHandler = handler; }, close: () => { closed = true; clearTimeout(timer); socket?.close?.(); }, state: () => ({ connected: socket?.readyState === 1, expectedVersion }) };
27
+ }
@@ -0,0 +1,15 @@
1
+ const READ_PATTERN = /^(SELECT|SHOW|DESCRIBE|DESC|EXPLAIN)\b/i;
2
+
3
+ export function classifyQuery(sql) {
4
+ if (typeof sql !== 'string') return 'primary';
5
+ const statement = sql.trim().replace(/^(--[^\n]*\n|#[^\n]*\n|\/\*[\s\S]*?\*\/\s*)+/, '').trim();
6
+ if (!READ_PATTERN.test(statement) || statement.includes(';')) return 'primary';
7
+ if (/\b(FOR\s+UPDATE|LOCK\s+IN\s+SHARE\s+MODE|INTO\s+(OUTFILE|DUMPFILE)|CALL)\b/i.test(statement)) return 'primary';
8
+ return 'balanced';
9
+ }
10
+
11
+ export function routeFor(sql, requested = 'auto') {
12
+ if (requested === 'primary' || requested === 'balanced') return requested;
13
+ if (requested !== 'auto') throw new TypeError(`Unsupported SQL route: ${requested}`);
14
+ return classifyQuery(sql);
15
+ }
@@ -0,0 +1,8 @@
1
+ const literal = (value) => `'${String(value).replaceAll("'", "''")}'`;
2
+ export function createSqlVerifier({ query } = {}) {
3
+ if (typeof query !== 'function') throw new TypeError('query function is required');
4
+ const connectivity = async () => { await query('SELECT 1 AS healthy'); return { verified: true }; };
5
+ const schema = async (database) => { const [rows] = await query(`SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME = ${literal(database)}`); return { database, verified: rows.length > 0 }; };
6
+ const account = async (user, host = '%') => { const [rows] = await query(`SHOW GRANTS FOR ${literal(user)}@${literal(host)}`); return { user, host, verified: rows.length > 0, grants: rows.map((row) => Object.values(row)[0]) }; };
7
+ return { connectivity, schema, account, async all({ database, user, host = '%' } = {}) { const [connection, structure, grants] = await Promise.all([connectivity(), schema(database), account(user, host)]); return { verified: connection.verified && structure.verified && grants.verified, connectivity: connection, schema: structure, account: grants }; } };
8
+ }