@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 +15 -0
- package/README.md +74 -0
- package/RELEASE_NOTES.md +71 -0
- package/THIRD-PARTY-NOTICES.md +5 -0
- package/package.json +51 -0
- package/src/admin/migrations.mjs +27 -0
- package/src/admin/sql.mjs +7 -0
- package/src/bundle.mjs +17 -0
- package/src/client/create-db.mjs +39 -0
- package/src/client/environment.mjs +7 -0
- package/src/client/from-bundle.mjs +14 -0
- package/src/client/route-factory.mjs +18 -0
- package/src/config.mjs +31 -0
- package/src/credential-provider.mjs +12 -0
- package/src/errors.mjs +18 -0
- package/src/index.d.ts +66 -0
- package/src/index.mjs +14 -0
- package/src/lifecycle/materializer.mjs +16 -0
- package/src/lifecycle/quiesce.mjs +20 -0
- package/src/pools/node-pool.mjs +15 -0
- package/src/pools/route-pool.mjs +39 -0
- package/src/pools.mjs +3 -0
- package/src/routing/node-set.mjs +6 -0
- package/src/routing/stream-client.mjs +27 -0
- package/src/routing.mjs +15 -0
- package/src/verification/sql.mjs +8 -0
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
|
+
```
|
package/RELEASE_NOTES.md
ADDED
|
@@ -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.
|
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,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
|
+
}
|
package/src/routing.mjs
ADDED
|
@@ -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
|
+
}
|