@eliware/elera-lib 0.1.11 → 0.3.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/README.md CHANGED
@@ -1,129 +1,32 @@
1
1
  # @eliware/elera-lib
2
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.11 alternative to `@eliware/mysql`; the
6
- existing package is intentionally unchanged. The current package version is
7
- 0.1.11.
8
-
9
- `primary` is the preferred connection path. `balanced` is an optional alternate
10
- path. Both may accept writes; automatic routing sends only conservative,
11
- single-statement read queries to `balanced`. Transactions always use `primary`.
12
-
13
- ```js
14
- import { createDbFromEnvironment } from '@eliware/elera-lib';
15
- const db = await createDbFromEnvironment();
16
- await db.query('SELECT 1');
17
- await db.close();
18
- ```
19
-
20
- Environment variables: `MYSQL_PRIMARY_HOST` (or `MYSQL_HOST`),
21
- `MYSQL_PRIMARY_PORT` (or `MYSQL_PORT`), optional `MYSQL_BALANCED_HOST`,
22
- optional `MYSQL_BALANCED_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, and
23
- `MYSQL_DATABASE`. `MYSQL_SOCKET` optionally selects a Unix-domain socket for
24
- the primary connection, which is useful for local socket-authenticated MariaDB
25
- accounts. Pool settings may be supplied with `MYSQL_CONNECT_TIMEOUT`,
26
- `MYSQL_ACQUIRE_TIMEOUT`, `MYSQL_CONNECTION_LIMIT`, `MYSQL_QUEUE_LIMIT`, and
27
- `MYSQL_SSL`. Configure primary and balanced routes explicitly; applications
28
- should not rely on ambiguous single-endpoint aliases.
29
-
30
- See examples/basic-client.mjs for a complete consumer example using only the
31
- public package API. Its usage notes are in examples/README.md.
32
-
33
- Routing bundles passed to `createDbFromBundle` use the normalized shape
34
- `routes.primary` and `routes.balanced`, each containing ordered `{ host, port,
35
- weight }` nodes. A bundle may also carry an explicit `writer`, ordered
36
- `failover`, and `readers` assignment. The bundle carries `database`,
37
- `identity`, optional `application`, `credentialName`, `scopes`, and
38
- `credentials`, and `expiresAt`; `validateBundle` rejects
39
- expired, malformed, duplicated, or conflicting route data. The checked-in
40
- contract fixture documents the supervisor-facing wire representation
41
- separately.
42
-
43
- The implementation accepts optional routing bundles and injected credential
44
- providers, maintains bounded pools per route, supports ordered writer/reader
45
- candidates, bundle refresh, and quarantine of unhealthy nodes. The WebSocket
46
- routing-event transport is implemented as a generic adapter. Nodes can be
47
- immediately excluded or re-admitted with `client.setNodeAvailability(route,
48
- host, available)`. These are generic client capabilities: the
49
- library does not know about supervisors, Elera, HAProxy, GitOps, backups, or
50
- CLI commands. Applications provide those integrations through ordinary
51
- configuration and callbacks.
52
-
53
- An application-scoped token should resolve to one application, database, and
54
- credential context. The library does not select databases or credentials from
55
- request arguments. Callers that already have that authorization context may
56
- pass `tokenContext` to `createDb`; bundle creation and refresh then reject
57
- cross-database, identity, credential, or scope mismatches.
58
-
59
- When a route node is drained, the client immediately stops assigning new work
60
- to that node while existing operations continue. The drain window defaults to
61
- 45 seconds and is capped at 45 seconds; remaining pool connections are then
62
- force-closed. `client.drain(host)` returns `wait()` and `forceClose()`
63
- operations, while `client.nodeStates()` exposes lifecycle and active-operation
64
- state. Only conservative, single-statement reads are eligible for automatic
65
- retry after a connection failure; uncertain writes are never retried
66
- automatically.
67
-
68
- `client.availability()` reports whether a primary route is usable. It returns
69
- `state: 'cluster-unavailable'` when every primary candidate is draining or
70
- unavailable; the `routes` fields report primary and balanced availability
71
- independently. A single-node route fails with the exported
72
- `ServerUnavailableError` using code `SERVER_UNAVAILABLE`; a multi-node route
73
- with no eligible candidates fails with `ClusterUnavailableError` using code
74
- `CLUSTER_UNAVAILABLE`.
75
-
76
- When an attached routing stream receives a `routing.shutdown` event, the
77
- client drains the identified node, performs a REST bundle resynchronization,
78
- and closes the retiring WebSocket with restart code `1012`. If the event
79
- contains `loadBalancerEndpoint`, that endpoint replaces the current endpoint
80
- before resynchronization and reconnect. `reconnectDeadlineMs` bounds the
81
- planned reconnect window; after it expires, no new reconnect is scheduled.
82
- Reconnects, failovers, and measured reconnect delay are included in telemetry.
83
- If the WebSocket remains unavailable before the deadline, REST
84
- resynchronization and bounded reconnect backoff continue until the deadline or
85
- until the caller closes the stream.
86
-
87
- Routing events are validated before application. Shutdown events may include
88
- `node`, `reason`, `reconnect`, `reconnectDeadlineMs`, and
89
- `loadBalancerEndpoint`; invalid event fields are reported through `onError` and
90
- do not change routing state. `routing.update` replaces the writer and reader
91
- pools atomically, while `routing.drain` excludes only the named node from new
92
- work and allows active operations to finish.
93
-
94
- The public client intentionally exposes SQL operations, health, routing,
95
- lifecycle, and optional routing-event synchronization methods. REST and
96
- WebSocket transports are adapters, not supervisor or CLI policy. Underlying
97
- `mysql2` pools and driver objects remain internal implementation details.
98
-
99
- For maintenance workflows, `createQuiesceController` provides a generic
100
- connection-admission drain and `createSqlVerifier` provides generic connectivity,
101
- schema, account, and grant checks. Neither API transports or orchestrates dump
102
- contents. The stream reports `websocket`, `rest`, or `disconnected` mode so
103
- callers can observe transport health without implementing transport policy.
104
-
105
- Applications may opt into generic in-memory telemetry with
106
- `createDb({ ..., telemetry: true })`. Query, execute, and transaction counts,
107
- failures, retries, in-flight work, and latency are exposed through
108
- `client.telemetry` and sent over an attached routing stream once per second.
109
- Telemetry is observational only; it does not carry SQL or credentials.
110
- The snapshot may include the application, database, credential name, and
111
- scopes associated with the already-authorized client, plus reconnect, failover,
112
- and cumulative reconnect-delay counters. It never includes bearer tokens or
113
- passwords.
114
-
115
- `createMaterializer` supports bounded plaintext use for a caller-provided
116
- operation. It creates a mode-restricted temporary file and removes its entire
117
- temporary directory in a `finally` block; this limits lifetime and cleanup but
118
- does not hide plaintext from the caller. The library does not persist secrets,
119
- age keys, or supervisor-specific artifact metadata.
120
-
121
- The package exports the SQL client and environment/bundle factories, query
122
- classification and route selection, routing-bundle validation, generic REST
123
- and WebSocket routing adapters, SQL administration and verification helpers,
124
- and lifecycle helpers for quiescing and temporary materialization. These
125
- helpers remain policy-neutral and do not provision users, manage clusters, or
126
- perform backup/restore orchestration.
3
+ `@eliware/elera-lib` is Elera's shared protocol and helper package. It contains
4
+ the contracts and policy primitives used by the supervisor, CLI, and
5
+ `@eliware/elera-client`.
6
+
7
+ It provides routing-bundle validation, version comparison, writer assignment,
8
+ ordered failover helpers, routing-event validation, shared SQL and availability
9
+ errors, the client drain policy, and transport-neutral in-memory telemetry.
10
+
11
+ Application code that needs native SQL connections must install
12
+ `@eliware/elera-client`. SQL pools, credentials, WebSocket connections,
13
+ database selection, backups, CLI commands, and supervisor orchestration are
14
+ not part of this package.
15
+
16
+ Routing bundles use normalized `routes.primary` and `routes.balanced` arrays
17
+ containing ordered `{ host, port, weight }` nodes. A bundle may also carry a
18
+ writer, readers, ordered failover nodes, application/database/identity scope,
19
+ credentials, version, and expiry. `validateBundle` rejects malformed,
20
+ expired, duplicated, or conflicting route data.
21
+
22
+ Routing events are validated before consumers act on them. Shared errors
23
+ distinguish an unavailable server from an unavailable cluster. The drain policy
24
+ caps client drain windows at 45 seconds. Telemetry is observational and never
25
+ contains bearer tokens, passwords, SQL, or connection pools.
26
+
27
+ The supervisor and CLI own policy, persistence, authentication, provisioning,
28
+ recovery, and transport orchestration. `elera-lib` supplies reusable contracts
29
+ and helpers only.
127
30
 
128
31
  ## Development
129
32
 
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,76 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.3.0 — Shared protocol and helper boundary
4
+
5
+ ### Breaking changes
6
+
7
+ - Narrows `@eliware/elera-lib` to shared Elera contracts, validation, routing,
8
+ lifecycle, telemetry, and error helpers.
9
+ - Removes the managed SQL client, SQL pools, credential providers, and
10
+ application-facing database orchestration from this package. Those concerns
11
+ belong to `@eliware/elera-client` or repository-specific implementations.
12
+ - Removes supervisor- and CLI-specific administration, provisioning, and
13
+ connection behavior from the public library.
14
+
15
+ ### Changed
16
+
17
+ - Makes routing-bundle validation enforce the complete shared contract,
18
+ including application, database, identity, credentials, writer, readers,
19
+ failover, version, expiry, node identity, and service ports.
20
+ - Aligns the checked-in routing schema and fixture with the shared wire
21
+ contract.
22
+ - Synchronizes the public runtime exports and TypeScript declarations with the
23
+ reduced shared-library boundary.
24
+ - Keeps REST/WebSocket transport behavior, routing failover, lifecycle policy,
25
+ and telemetry primitives transport- and application-policy-neutral.
26
+
27
+ ### Verification
28
+
29
+ - Adds focused validation for the complete routing-bundle contract, routing
30
+ nodes, shared errors, and public exports.
31
+ - Removes client-specific tests and implementation from the shared package so
32
+ they can be maintained by `@eliware/elera-client`.
33
+ - Contract verification, tests, lint, and coverage checks pass for the shared
34
+ library.
35
+
36
+ ## 0.2.0 — Managed endpoint and token client
37
+
38
+ ### Breaking changes
39
+
40
+ - Adds a new managed-client workflow; applications using it no longer provide
41
+ SQL hosts, database names, usernames, passwords, or routing profiles.
42
+ - Managed bundle updates are now constrained to the authorization context
43
+ established by the initial bundle.
44
+
45
+ ### Added
46
+
47
+ - Defines the managed application contract using only
48
+ `ELERA_API_ENDPOINT` and `ELERA_API_TOKEN`.
49
+ - Adds authenticated `fetchRoutingBundle` REST retrieval with response
50
+ validation and a configurable bundle path.
51
+ - Makes `createDb()` the sole application-facing managed workflow. It acquires the
52
+ initial bundle and attaches the routing stream automatically, using explicit
53
+ options or `ELERA_API_ENDPOINT` and `ELERA_API_TOKEN` by default.
54
+ - Establishes application, database, identity, credential, and scope context
55
+ from the initial bundle and rejects cross-context updates.
56
+ - Adds explicit routing-bundle metadata for application, database, and
57
+ identity IDs, node identity, and service ports.
58
+ - Handles routing updates that temporarily remove all eligible writers or
59
+ readers without dereferencing missing routes.
60
+ - Prevents concurrent stream connections and reconnects after a shutdown
61
+ deadline.
62
+
63
+ ### API boundary
64
+
65
+ - The managed factory is the application-facing workflow and accepts only the
66
+ endpoint and application token.
67
+
68
+ ### Verification
69
+
70
+ - Adds focused bundle-fetcher, managed-client, lifecycle, and
71
+ authorization-boundary tests.
72
+ - Static syntax, schema, and diff validation pass for the committed changes.
73
+
3
74
  ## 0.1.11 — Public runtime declarations
4
75
 
5
76
  ### Changed
@@ -306,10 +377,9 @@ GitOps, backup, or CLI policy.
306
377
  policy.
307
378
  - Requires applications to provide their own credential and routing adapters.
308
379
 
309
- ### Compatibility and validation
380
+ ### Validation
310
381
 
311
382
  - ESM package targeting Node.js 26 or newer.
312
383
  - TypeScript declarations are included with the package.
313
- - Existing `@eliware/mysql` is not modified or required.
314
384
  - Strict test coverage is maintained at 100% statements, branches, functions,
315
385
  and lines with zero lint warnings.
@@ -0,0 +1,36 @@
1
+ # Shared contract boundary
2
+
3
+ This document describes the bundle and event contract consumed by Elera
4
+ components. It is not an application-client API; application-facing SQL
5
+ connections belong to `@eliware/elera-client`.
6
+
7
+ ## Routing bundles
8
+
9
+ The supervisor publishes a complete `v1` bundle containing application,
10
+ database, identity, credentials, writer, readers, ordered failover nodes,
11
+ bundle version, expiry, node identity, and service ports. The normalized route
12
+ arrays are `routes.primary` and `routes.balanced`.
13
+
14
+ `validateBundle()` rejects missing required fields, malformed nodes or ports,
15
+ expired timestamps, duplicate writer/failover nodes, and invalid route arrays.
16
+ Consumers must validate a complete bundle before using it. Partial routing
17
+ events must be merged with the active complete bundle by the consumer before
18
+ validation.
19
+
20
+ ## Routing events
21
+
22
+ `validateRoutingEvent()` defines the shared event boundary for routing updates,
23
+ drains, recovery, and shutdown. Event handling and transport orchestration are
24
+ owned by the consuming client or supervisor; this package supplies contracts
25
+ and validation only.
26
+
27
+ ## Shared helpers
28
+
29
+ The public package provides bundle and node validation, version comparison,
30
+ writer/failover calculation, shared SQL errors, client drain policy, and
31
+ transport-neutral telemetry. It does not retrieve bundles, open SQL pools,
32
+ materialize application credentials, provision databases, manage Galera, or
33
+ implement CLI and supervisor workflows.
34
+
35
+ See `README.md` for the complete export boundary and
36
+ `contracts/routing-bundle.schema.json` for the machine-readable contract.
@@ -0,0 +1,17 @@
1
+ {
2
+ "apiVersion": "v1",
3
+ "application": "billing",
4
+ "applicationId": "18446744073709551621",
5
+ "databaseId": "18446744073709551622",
6
+ "identityId": "18446744073709551623",
7
+ "database": "billing",
8
+ "identity": "billing-runtime",
9
+ "credentials": { "username": "billing_runtime", "password": "fixture-only" },
10
+ "writer": { "host": "sql0.internal", "port": 3306 },
11
+ "failover": [{ "host": "sql1.internal", "port": 3306 }],
12
+ "readers": [{ "host": "sql0.internal", "port": 3306, "weight": 100 }, { "host": "sql1.internal", "port": 3306, "weight": 80 }, { "host": "sql2.internal", "port": 3306, "weight": 60 }],
13
+ "nodeIdentity": "sql0.internal",
14
+ "ports": { "sql": 3306, "http": 8080 },
15
+ "bundleVersion": 1,
16
+ "expiresAt": "2099-01-01T00:00:00Z"
17
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://eliware.dev/contracts/routing-bundle.schema.json",
4
+ "type": "object",
5
+ "required": ["apiVersion", "application", "database", "identity", "credentials", "writer", "readers", "failover", "bundleVersion", "expiresAt", "nodeIdentity", "ports"],
6
+ "properties": { "apiVersion": { "const": "v1" }, "application": { "type": "string", "minLength": 1 }, "applicationId": { "type": "string", "minLength": 1 }, "databaseId": { "type": "string", "minLength": 1 }, "identityId": { "type": "string", "minLength": 1 }, "database": { "type": "string", "minLength": 1 }, "identity": { "type": "string", "minLength": 1 }, "credentials": { "type": "object", "required": ["username", "password"], "properties": { "username": { "type": "string", "minLength": 1 }, "password": { "type": "string" } }, "additionalProperties": false }, "writer": { "$ref": "#/$defs/node" }, "failover": { "$ref": "#/$defs/nodes" }, "readers": { "$ref": "#/$defs/nodes" }, "nodeIdentity": { "type": "string", "minLength": 1 }, "ports": { "$ref": "#/$defs/ports" }, "bundleVersion": { "type": ["string", "number"] }, "refreshAfter": { "type": "string", "format": "date-time" }, "expiresAt": { "type": "string", "format": "date-time" } },
7
+ "$defs": { "node": { "type": "object", "required": ["host"], "properties": { "host": { "type": "string", "minLength": 1 }, "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, "weight": { "type": "number", "minimum": 0 } }, "additionalProperties": false }, "nodes": { "type": "array", "items": { "$ref": "#/$defs/node" } }, "ports": { "type": "object", "required": ["sql", "http"], "properties": { "sql": { "type": "integer", "minimum": 1, "maximum": 65535 }, "http": { "type": "integer", "minimum": 1, "maximum": 65535 }, "ws": { "type": "integer", "minimum": 1, "maximum": 65535 } }, "additionalProperties": { "type": "integer", "minimum": 1, "maximum": 65535 } } },
8
+ "additionalProperties": false
9
+ }
@@ -1,13 +1,11 @@
1
1
  # Elera library example
2
2
 
3
- basic-client.mjs is a minimal consumer application. It imports only the
4
- public @eliware/elera-lib package API, opens a bounded SQL client from
5
- environment configuration, performs a health check and query, and closes the
6
- client in a finally block.
3
+ `basic-client.mjs` demonstrates the shared `@eliware/elera-lib` contract
4
+ helpers. Native SQL application examples belong to `@eliware/elera-client`.
7
5
 
8
- Run it from a published-package consumer project with:
6
+ Run it with a complete bundle:
9
7
 
10
- MYSQL_PRIMARY_HOST=127.0.0.1 MYSQL_USER=app MYSQL_PASSWORD=secret MYSQL_DATABASE=app node examples/basic-client.mjs
8
+ ELERA_BUNDLE_JSON='{"apiVersion":"v1","application":"billing","database":"billing","identity":"billing-runtime","credentials":{"username":"billing_runtime","password":"fixture-only"},"writer":{"host":"elera-0","port":3306},"readers":[{"host":"elera-0","port":3306}],"failover":[{"host":"elera-1","port":3306}],"bundleVersion":1,"expiresAt":"2099-01-01T00:00:00Z","nodeIdentity":"elera-0","ports":{"sql":3306,"http":8080},"routes":{"primary":[{"host":"elera-0","port":3306}],"balanced":[{"host":"elera-0","port":3306}]}}' node examples/basic-client.mjs
11
9
 
12
- The example is intentionally infrastructure-neutral. It does not contain
13
- Docker, Kubernetes, Supervisor, CLI, Galera, or test-lab setup.
10
+ The example is infrastructure-neutral. It contains no Docker, Kubernetes,
11
+ Supervisor, CLI, Galera, or test-lab setup.
@@ -1,14 +1,7 @@
1
- import { createDbFromEnvironment } from '@eliware/elera-lib';
1
+ import { createTelemetry, validateBundle } from '@eliware/elera-lib';
2
2
 
3
- // Set MYSQL_PRIMARY_HOST, MYSQL_USER, MYSQL_PASSWORD, and MYSQL_DATABASE
4
- // before running this example. The same public API works with a routing bundle.
5
- const db = await createDbFromEnvironment();
3
+ // Native SQL application examples live in @eliware/elera-client.
4
+ const bundle = validateBundle(JSON.parse(process.env.ELERA_BUNDLE_JSON ?? '{}'));
5
+ const telemetry = createTelemetry({ application: bundle.application, database: bundle.database });
6
6
 
7
- try {
8
- const health = await db.health('primary');
9
- if (!health.ok) throw new Error('primary SQL route is not healthy');
10
- const [rows] = await db.query('SELECT 1 AS healthy');
11
- console.log(JSON.stringify({ health, rows }));
12
- } finally {
13
- await db.close();
14
- }
7
+ console.log(JSON.stringify({ bundleVersion: bundle.bundleVersion, telemetry: telemetry.snapshot() }));
package/package.json CHANGED
@@ -1,19 +1,21 @@
1
1
  {
2
2
  "name": "@eliware/elera-lib",
3
- "version": "0.1.11",
4
- "description": "Generic MySQL and MariaDB client with resilient routing, telemetry, client-side drains, and failover",
3
+ "version": "0.3.0",
4
+ "description": "Shared Elera routing, event, lifecycle, telemetry, validation, and protocol helpers",
5
5
  "keywords": [
6
6
  "eliware",
7
7
  "elera",
8
8
  "mysql",
9
9
  "mariadb",
10
- "sql",
11
- "database",
10
+ "galera",
11
+ "protocol",
12
+ "contracts",
12
13
  "routing",
13
14
  "failover",
14
15
  "telemetry",
15
16
  "websocket",
16
- "connection-pool"
17
+ "protocol-contracts",
18
+ "mysql-client"
17
19
  ],
18
20
  "repository": {
19
21
  "type": "git",
@@ -25,6 +27,7 @@
25
27
  },
26
28
  "license": "Apache-2.0",
27
29
  "type": "module",
30
+ "sideEffects": false,
28
31
  "engines": {
29
32
  "node": ">=26"
30
33
  },
@@ -36,6 +39,7 @@
36
39
  },
37
40
  "files": [
38
41
  "src",
42
+ "contracts",
39
43
  "examples",
40
44
  "README.md",
41
45
  "RELEASE_NOTES.md",
@@ -54,8 +58,7 @@
54
58
  },
55
59
  "dependencies": {
56
60
  "@eliware/common": "^2.0.0",
57
- "@eliware/snowflake": "^2.0.0",
58
- "mysql2": "^3.24.2"
61
+ "@eliware/snowflake": "^2.0.0"
59
62
  },
60
63
  "devDependencies": {
61
64
  "@eliware/test": "^2.0.0",
package/src/bundle.mjs CHANGED
@@ -1,13 +1,28 @@
1
1
  import { validateRoutingNode, validateRoutingNodes } from './routing/node-validation.mjs';
2
2
  const routes = ['primary', 'balanced'];
3
+ const requiredText = (value, name) => { if (typeof value !== 'string' || value.length === 0) throw new TypeError(`${name} is required`); };
4
+ const validatePorts = (ports) => {
5
+ if (!ports || typeof ports !== 'object') throw new TypeError('routing bundle ports are required');
6
+ for (const name of ['sql', 'http']) validateRoutingNode({ host: name, port: ports[name] }, `routing bundle ports.${name}`);
7
+ };
3
8
 
4
9
  export function validateBundle(bundle) {
5
10
  if (!bundle || typeof bundle !== 'object') throw new TypeError('routing bundle is required');
11
+ if (bundle.apiVersion !== 'v1') throw new TypeError('routing bundle apiVersion must be v1');
12
+ requiredText(bundle.application, 'routing bundle application');
13
+ requiredText(bundle.database, 'routing bundle database');
14
+ requiredText(bundle.identity, 'routing bundle identity');
15
+ requiredText(bundle.nodeIdentity, 'routing bundle nodeIdentity');
16
+ if (!bundle.credentials || typeof bundle.credentials !== 'object') throw new TypeError('routing bundle credentials are required');
17
+ requiredText(bundle.credentials.username, 'routing bundle credentials.username');
18
+ if (typeof bundle.credentials.password !== 'string') throw new TypeError('routing bundle credentials.password is required');
19
+ if (bundle.bundleVersion === undefined || !['string', 'number'].includes(typeof bundle.bundleVersion)) throw new TypeError('routing bundle bundleVersion is required');
20
+ validatePorts(bundle.ports);
6
21
  if (!bundle.expiresAt || Number.isNaN(Date.parse(bundle.expiresAt))) throw new TypeError('routing bundle expiresAt is required');
7
- const writer = bundle.writer !== undefined ? validateRoutingNode(bundle.writer, 'routing bundle writer') : undefined;
8
- const failover = bundle.failover !== undefined ? validateRoutingNodes(bundle.failover, 'routing bundle failover') : [];
22
+ const writer = validateRoutingNode(bundle.writer, 'routing bundle writer');
23
+ const failover = validateRoutingNodes(bundle.failover, 'routing bundle failover');
9
24
  if (writer && failover.some((node) => node.host === writer.host && node.port === writer.port)) throw new TypeError('routing bundle failover duplicates writer');
10
- if (bundle.readers !== undefined) validateRoutingNodes(bundle.readers, 'routing bundle readers');
25
+ validateRoutingNodes(bundle.readers, 'routing bundle readers');
11
26
  for (const route of routes) {
12
27
  if (bundle.routes?.[route] !== undefined && !Array.isArray(bundle.routes[route])) throw new TypeError(`bundle.routes.${route} must be an array`);
13
28
  for (const node of bundle.routes?.[route] ?? []) {
package/src/index.d.ts CHANGED
@@ -1,96 +1,21 @@
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> & { socket?: string };
10
- }
11
-
12
- export interface RoutingNode { host: string; port: number | string; weight?: number; }
13
- export interface WriterAssignment { host: string; port: number | string; }
14
- export interface RoutingBundle {
15
- apiVersion?: string;
16
- database?: string;
17
- identity?: string;
18
- application?: string;
19
- credentialName?: string;
20
- scopes?: string[];
21
- credentials?: { username?: string; password?: string };
22
- bundleVersion?: number | string;
23
- expiresAt: string;
24
- refreshAfter?: string;
25
- routes: { primary?: RoutingNode[]; balanced?: RoutingNode[] };
26
- writer?: WriterAssignment;
27
- failover?: WriterAssignment[];
28
- readers?: WriterAssignment[];
29
- }
30
- export interface CredentialProviderResult { user: string; password: string; }
31
- export type CredentialProvider = (context: { database: string; identity: string | null; route: string }) => Promise<CredentialProviderResult> | CredentialProviderResult;
32
- export type QueryFunction = (sql: string, values?: unknown) => Promise<any>;
33
- export interface DbOptions { route?: 'auto' | 'primary' | 'balanced'; connection?: unknown; }
1
+ export interface RoutingNode { host: string; port: number | string; weight?: number; nodeId?: string; }
2
+ export interface RoutingBundle { apiVersion?: string; applicationId?: string; databaseId?: string; identityId?: string; application?: string; database?: string; identity?: string; credentialName?: string; scopes?: string[]; credentials?: { username?: string; password?: string }; bundleVersion?: number | string; expiresAt: string; refreshAfter?: string; routes: { primary?: RoutingNode[]; balanced?: RoutingNode[] }; writer?: RoutingNode; failover?: RoutingNode[]; readers?: RoutingNode[]; nodeIdentity?: Record<string, unknown>; ports?: Record<string, number | string>; }
34
3
  export interface TelemetrySnapshot { type: 'client.telemetry'; application: string; credentialName?: string; database?: string; scopes?: string[]; queries: number; failures: number; retries: number; reconnects: number; failoverCount: number; reconnectDelayMs: number; inflight: number; totalLatencyMs: number; maxLatencyMs: number; avgLatencyMs: number; sentAt: string; }
35
- export interface Telemetry { begin(): number; record(event?: { latencyMs?: number; failed?: boolean; retry?: boolean; reconnect?: boolean; failover?: boolean }): void; recordReconnect(event?: { delayMs?: number; failover?: boolean }): void; snapshot(): TelemetrySnapshot; start(stream: Pick<RoutingStream, 'sendTelemetry'>): void; stop(): void; }
36
-
37
- export interface DbClient {
38
- query(sql: string, values?: unknown, options?: DbOptions): Promise<unknown>;
39
- execute(sql: string, values?: unknown, options?: DbOptions): Promise<unknown>;
40
- transaction<T>(callback: (transaction: Pick<DbClient, 'query' | 'execute'>) => Promise<T>): Promise<T>;
41
- health(route?: 'primary' | 'balanced'): Promise<{ ok: boolean; route: string; latencyMs: number; telemetry?: TelemetrySnapshot }>;
42
- close(): Promise<void>;
43
- refresh(bundle: RoutingBundle): Promise<{ bundleVersion: number | string | null; refreshRequired: boolean }>;
44
- bundle(): RoutingBundle | undefined;
45
- classify(sql: string): 'primary' | 'balanced';
46
- attachRoutingStream(stream: RoutingStream): Promise<() => void>;
47
- setNodeAvailability(route: 'primary' | 'balanced', host: string, available: boolean): void;
48
- availability(): { state: 'available' | 'cluster-unavailable'; routes: { primary: boolean; balanced: boolean } };
49
- drain(host: string, timeoutMs?: number): { host: string; timeoutMs: number; wait(): Promise<unknown[]>; forceClose(): Promise<unknown[]> };
50
- nodeStates(): Array<{ host: string; port: number; route: 'primary' | 'balanced'; state: 'ready' | 'draining' | 'unavailable' | 'recovering'; active: number; available: boolean }>;
51
- config: { primary: ConnectionProfile; balanced?: ConnectionProfile };
52
- telemetry?: Telemetry;
53
- }
54
-
55
- export interface RoutingStream {
56
- connect(): Promise<void>;
57
- setOnUpdate(handler: (event: unknown) => void | Promise<void>): void;
58
- close(): void;
59
- sendTelemetry(payload: unknown): void;
60
- setTelemetry?(telemetry?: Pick<Telemetry, 'recordReconnect'>): void;
61
- state(): { connected: boolean; mode: 'websocket' | 'rest' | 'disconnected'; expectedVersion: number | string; endpoint: string; reconnectDeadlineAt?: number };
62
- }
63
-
64
- export type RoutingEvent = { type: 'routing.update' | 'routing.resync' | 'routing.drain' | 'routing.recovery'; node?: string; version?: number | string; [key: string]: unknown } | { type: 'routing.shutdown'; node?: string; reason?: string; reconnect?: boolean; reconnectDeadlineMs?: number; loadBalancerEndpoint?: string };
65
- export function validateRoutingEvent(event: unknown): RoutingEvent;
66
-
67
- export function createDb(options: { primary: ConnectionProfile; balanced?: Partial<ConnectionProfile>; bundle?: RoutingBundle; tokenContext?: { application?: string; database?: string; credentialName?: string; identity?: string; scopes?: string[] }; credentialProvider?: CredentialProvider; identity?: string; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; quarantineMs?: number; drainTimeoutMs?: number; now?: () => number; telemetry?: true | Telemetry }): Promise<DbClient>;
68
- export function profilesFromBundle(bundle: RoutingBundle): { primary: ConnectionProfile; balanced?: ConnectionProfile };
69
- export function createDbFromBundle(options: { bundle: RoutingBundle; createClient?: typeof createDb; credentialProvider?: CredentialProvider; tokenContext?: { application?: string; database?: string; credentialName?: string; identity?: string; scopes?: string[] }; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; quarantineMs?: number; drainTimeoutMs?: number; now?: () => number; telemetry?: true | Telemetry }): Promise<DbClient>;
70
- export function validateTokenContext(bundle: RoutingBundle, tokenContext?: { application?: string; database?: string; credentialName?: string; identity?: string; scopes?: string[] }): RoutingBundle;
71
- export function createDbFromEnvironment(options?: { env?: Record<string, string | undefined>; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string; telemetry?: true | Telemetry }): Promise<DbClient>;
72
- export function classifyQuery(sql: unknown): 'primary' | 'balanced';
73
- export function routeFor(sql: unknown, requested?: 'auto' | 'primary' | 'balanced'): 'primary' | 'balanced';
74
- export function validateProfile(profile: ConnectionProfile, name?: string): ConnectionProfile;
75
- export function redactedProfile(profile: ConnectionProfile): ConnectionProfile;
76
- export class SqlClientError extends Error { code?: string; retryable?: boolean; cause?: unknown; }
77
- export class ClusterUnavailableError extends SqlClientError {}
78
- export class ServerUnavailableError extends SqlClientError {}
79
- export function classifyError(error: unknown): { retryable: boolean; code?: string };
80
- export function asSqlError(error: unknown): SqlClientError;
4
+ export interface Telemetry { snapshot(): TelemetrySnapshot; begin(): number; record(event?: unknown): void; recordReconnect(event?: unknown): void; }
81
5
  export function validateBundle(bundle: RoutingBundle): RoutingBundle;
82
6
  export function bundleExpired(bundle: RoutingBundle, now?: number): boolean;
83
7
  export function bundleNeedsRefresh(bundle: RoutingBundle, now?: number): boolean;
84
- export function createAdminSql(options: { query: QueryFunction }): { transaction<T>(work: (context: { query: QueryFunction }) => Promise<T>): Promise<T>; migration(statements?: string[]): Promise<unknown> };
85
- export function createMigrationRunner(options: { query: QueryFunction; migrations?: Array<{ version: number; name: string; statements: string[] }> }): { status(): Promise<{ applied: unknown[] }>; migrate(): Promise<unknown> };
86
- export function selectRouteNodes(options: { bundle: RoutingBundle; route?: 'primary' | 'balanced'; now?: number }): RoutingNode[];
87
- export function createRoutingStream(options: { endpoint: string; token?: string; fetchBundle: () => Promise<RoutingBundle>; onUpdate?: (event: unknown) => void; onError?: (error: unknown) => void; reconnectMs?: number; maxReconnectMs?: number; heartbeatMs?: number; telemetry?: Pick<Telemetry, 'recordReconnect'> }): RoutingStream;
88
- export function writerAssignment(bundle: RoutingBundle): WriterAssignment;
89
- export function failoverNodes(bundle: RoutingBundle): WriterAssignment[];
8
+ export function validateRoutingEvent(event: unknown): unknown;
90
9
  export function compareBundleVersions(left: number | string | undefined, right: number | string | undefined): number;
10
+ export function writerAssignment(bundle: RoutingBundle): RoutingNode;
11
+ export function failoverNodes(bundle: RoutingBundle): RoutingNode[];
12
+ export function validateRoutingNode(node: RoutingNode, name?: string): RoutingNode;
13
+ export function validateRoutingNodes(nodes: RoutingNode[], name?: string): RoutingNode[];
91
14
  export const CLIENT_DRAIN_TIMEOUT_MS: 45000;
92
15
  export function clientDrainTimeout(timeoutMs?: number): number;
93
- 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> };
94
- 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> };
95
- export function createMaterializer(options?: Record<string, unknown>): unknown;
96
- export function createTelemetry(options?: { application?: string; credentialName?: string; database?: string; scopes?: string[]; intervalMs?: number; now?: () => number; setIntervalImpl?: typeof setInterval; clearIntervalImpl?: typeof clearInterval }): Telemetry;
16
+ export function createTelemetry(options?: Record<string, unknown>): Telemetry;
17
+ export class SqlClientError extends Error { code?: string; retryable?: boolean; cause?: unknown; }
18
+ export class ClusterUnavailableError extends SqlClientError {}
19
+ export class ServerUnavailableError extends SqlClientError {}
20
+ export function classifyError(error: unknown): { retryable: boolean; code?: string };
21
+ export function asSqlError(error: unknown): SqlClientError;
package/src/index.mjs CHANGED
@@ -1,20 +1,8 @@
1
- export { createDb } from './client/create-db.mjs';
2
- export { validateTokenContext } from './client/authorization-context.mjs';
3
- export { createDbFromBundle, profilesFromBundle } from './client/from-bundle.mjs';
4
- export { createDbFromEnvironment } from './client/environment.mjs';
5
- export { classifyQuery, routeFor } from './routing.mjs';
6
- export { validateProfile, redactedProfile } from './config.mjs';
7
1
  export { SqlClientError, ClusterUnavailableError, ServerUnavailableError, classifyError, asSqlError } from './errors.mjs';
8
2
  export { validateBundle, bundleExpired, bundleNeedsRefresh } from './bundle.mjs';
9
- export { createAdminSql } from './admin/sql.mjs';
10
- export { createMigrationRunner } from './admin/migrations.mjs';
11
- export { selectRouteNodes } from './routing/node-set.mjs';
12
- export { createRoutingStream } from './routing/stream-client.mjs';
13
3
  export { validateRoutingEvent } from './routing/event-contract.mjs';
14
- export { writerAssignment, failoverNodes } from './routing/assignment.mjs';
15
4
  export { compareBundleVersions } from './routing/bundle-version.mjs';
5
+ export { writerAssignment, failoverNodes } from './routing/assignment.mjs';
6
+ export { validateRoutingNode, validateRoutingNodes } from './routing/node-validation.mjs';
16
7
  export { CLIENT_DRAIN_TIMEOUT_MS, clientDrainTimeout } from './lifecycle/drain-policy.mjs';
17
- export { createQuiesceController } from './lifecycle/quiesce.mjs';
18
- export { createSqlVerifier } from './verification/sql.mjs';
19
- export { createMaterializer } from './lifecycle/materializer.mjs';
20
8
  export { createTelemetry } from './telemetry.mjs';
@@ -0,0 +1,20 @@
1
+ import { validateBundle } from '../bundle.mjs';
2
+
3
+ const DEFAULT_BUNDLE_PATH = '/api/v1/routing/bundle';
4
+
5
+ function bundleUrl(endpoint, path) {
6
+ if (!endpoint) throw new TypeError('Elera API endpoint is required');
7
+ return new URL(path, endpoint.endsWith('/') ? endpoint : `${endpoint}/`).toString();
8
+ }
9
+
10
+ export async function fetchRoutingBundle({ endpoint, token, path = DEFAULT_BUNDLE_PATH, fetchImpl = globalThis.fetch, signal } = {}) {
11
+ if (!token) throw new TypeError('Elera API token is required');
12
+ if (typeof fetchImpl !== 'function') throw new TypeError('fetch implementation is required');
13
+ const response = await fetchImpl(bundleUrl(endpoint, path), { method: 'GET', headers: { accept: 'application/json', authorization: `Bearer ${token}` }, signal });
14
+ if (!response?.ok) throw new Error(`routing bundle request failed with HTTP ${response?.status ?? 0}`);
15
+ let bundle;
16
+ try { bundle = await response.json(); } catch (error) { throw new Error('routing bundle response was not valid JSON', { cause: error }); }
17
+ return validateBundle(bundle);
18
+ }
19
+
20
+ export { DEFAULT_BUNDLE_PATH };
@@ -4,16 +4,18 @@ import { validateRoutingEvent } from './event-contract.mjs';
4
4
 
5
5
  export function createRoutingStream({ endpoint, token, fetchBundle, WebSocketImpl = globalThis.WebSocket, onUpdate, onError, reconnectMs = 1000, maxReconnectMs = 30000, heartbeatMs = 45000, now = () => Date.now(), telemetry } = {}) {
6
6
  if (!endpoint || typeof fetchBundle !== 'function') throw new TypeError('endpoint and fetchBundle are required');
7
- let socket; let closed = false; let timer; let heartbeat; let expectedVersion = 0; let delay = reconnectMs; let updateHandler = onUpdate; let mode = 'disconnected'; let plannedReconnect = false; let lastReconnectWasPlanned = false; let disconnectedAt; let reconnectDeadlineAt;
7
+ let socket; let closed = false; let connecting = false; let timer; let heartbeat; let expectedVersion = 0; let delay = reconnectMs; let updateHandler = onUpdate; let mode = 'disconnected'; let plannedReconnect = false; let lastReconnectWasPlanned = false; let disconnectedAt; let reconnectDeadlineAt;
8
8
  const log = arguments[0]?.log ?? defaultLog;
9
9
  const streamUrl = () => `${endpoint.replace(/^http/i, 'ws').replace(/\/$/, '')}/api/v1/routing/stream?token=${encodeURIComponent(token ?? '')}`;
10
10
  async function fallback() { try { const bundle = await fetchBundle(); if (closed) return; mode = 'rest'; updateHandler?.({ type: 'routing.resync', version: expectedVersion, bundle, receivedAt: now() }); } catch (error) { if (closed) return; mode = 'disconnected'; onError?.(error); log.warn?.('Routing REST fallback failed', { error }); } }
11
11
  function schedule() { if (closed || timer || (reconnectDeadlineAt !== undefined && now() >= reconnectDeadlineAt)) return; const wait = Math.min(delay, Math.max(0, reconnectDeadlineAt === undefined ? delay : reconnectDeadlineAt - now())); timer = setTimeout(() => { timer = undefined; void connect(); }, wait); delay = Math.min(maxReconnectMs, delay * 2); }
12
12
  async function connect() {
13
+ if (closed || connecting || socket?.readyState === 1 || (reconnectDeadlineAt !== undefined && now() >= reconnectDeadlineAt)) return;
13
14
  if (closed || typeof WebSocketImpl !== 'function') { await fallback(); schedule(); return; }
14
15
  try {
16
+ connecting = true;
15
17
  socket = new WebSocketImpl(streamUrl());
16
- socket.onopen = () => { mode = 'websocket'; reconnectDeadlineAt = undefined; if (disconnectedAt !== undefined) { telemetry?.recordReconnect?.({ delayMs: Math.max(0, now() - disconnectedAt), failover: lastReconnectWasPlanned }); disconnectedAt = undefined; lastReconnectWasPlanned = false; } delay = reconnectMs; heartbeat = setInterval(() => socket?.send?.(JSON.stringify({ type: 'heartbeat', sentAt: now() })), heartbeatMs); };
18
+ socket.onopen = () => { connecting = false; mode = 'websocket'; reconnectDeadlineAt = undefined; if (disconnectedAt !== undefined) { telemetry?.recordReconnect?.({ delayMs: Math.max(0, now() - disconnectedAt), failover: lastReconnectWasPlanned }); disconnectedAt = undefined; lastReconnectWasPlanned = false; } delay = reconnectMs; heartbeat = setInterval(() => socket?.send?.(JSON.stringify({ type: 'heartbeat', sentAt: now() })), heartbeatMs); };
17
19
  socket.onmessage = async ({ data }) => {
18
20
  try {
19
21
  const event = validateRoutingEvent(JSON.parse(data));
@@ -36,8 +38,8 @@ export function createRoutingStream({ endpoint, token, fetchBundle, WebSocketImp
36
38
  } catch (error) { onError?.(error); }
37
39
  };
38
40
  socket.onerror = (error) => { onError?.(error); };
39
- socket.onclose = () => { clearInterval(heartbeat); heartbeat = undefined; socket = undefined; mode = 'disconnected'; if (!closed) { disconnectedAt = now(); lastReconnectWasPlanned = plannedReconnect; if (!plannedReconnect) void fallback(); plannedReconnect = false; schedule(); } };
40
- } catch (error) { mode = 'disconnected'; onError?.(error); await fallback(); schedule(); }
41
+ socket.onclose = () => { connecting = false; clearInterval(heartbeat); heartbeat = undefined; socket = undefined; mode = 'disconnected'; if (!closed) { disconnectedAt = now(); lastReconnectWasPlanned = plannedReconnect; if (!plannedReconnect) void fallback(); plannedReconnect = false; schedule(); } };
42
+ } catch (error) { connecting = false; socket = undefined; mode = 'disconnected'; onError?.(error); await fallback(); schedule(); }
41
43
  }
42
44
  return { connect, sendTelemetry: (payload) => { if (socket?.readyState === 1) socket.send(JSON.stringify(payload)); }, setOnUpdate: (handler) => { updateHandler = handler; }, setTelemetry: (value) => { telemetry = value; }, close: () => { closed = true; mode = 'disconnected'; clearTimeout(timer); clearInterval(heartbeat); socket?.close?.(); }, state: () => ({ connected: socket?.readyState === 1, mode, expectedVersion, endpoint, reconnectDeadlineAt }) };
43
45
  }
@@ -1,9 +0,0 @@
1
- export function validateTokenContext(bundle, tokenContext = {}) {
2
- if (!bundle || typeof bundle !== 'object') throw new TypeError('routing bundle is required');
3
- for (const field of ['application', 'database', 'credentialName']) {
4
- if (tokenContext[field] !== undefined && bundle[field] !== tokenContext[field]) throw new Error(`routing bundle ${field} does not match token context`);
5
- }
6
- if (tokenContext.identity !== undefined && bundle.identity !== tokenContext.identity) throw new Error('routing bundle identity does not match token context');
7
- if (tokenContext.scopes !== undefined && (!Array.isArray(bundle.scopes) || tokenContext.scopes.some((scope) => !bundle.scopes.includes(scope)))) throw new Error('routing bundle scopes exceed token context');
8
- return bundle;
9
- }
@@ -1,7 +0,0 @@
1
- import { validateRoutingNode } from '../routing/node-validation.mjs';
2
-
3
- export function bundleProfiles(bundle, route, base) {
4
- if (route === 'primary' && bundle.writer?.host) return [bundle.writer, ...(bundle.failover ?? [])].map((node, index) => { const valid = validateRoutingNode(node, `primary assignment[${index}]`); return { ...base, host: valid.host, port: valid.port, weight: valid.weight }; });
5
- const nodes = route === 'balanced' && bundle.readers?.length ? bundle.readers : bundle.routes?.[route] ?? [];
6
- return nodes.map((node) => { const valid = validateRoutingNode(node, `${route} route node`); return { ...base, host: valid.host, port: valid.port, weight: valid.weight }; });
7
- }
@@ -1,53 +0,0 @@
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 as validateBundleShape, bundleExpired, bundleNeedsRefresh } from '../bundle.mjs';
7
- import { createRouteFactory } from './route-factory.mjs';
8
- import { classifyQuery, routeFor } from '../routing.mjs';
9
- import { compareBundleVersions } from '../routing/bundle-version.mjs';
10
- import { clientDrainTimeout } from '../lifecycle/drain-policy.mjs';
11
- import { createTelemetry } from '../telemetry.mjs';
12
- import { createTimedOperation } from './telemetry-wrapper.mjs';
13
- import { validateTokenContext } from './authorization-context.mjs';
14
-
15
- const olderVersion = (candidate, current) => compareBundleVersions(candidate, current) < 0;
16
-
17
- export async function createDb({ primary, balanced, bundle, credentialProvider, mysqlLib = mysql, log = defaultLog, routing = 'auto', identity, tokenContext, quarantineMs = 5000, drainTimeoutMs = 45000, now = () => Date.now(), telemetry } = {}) {
18
- if (!primary || typeof primary !== 'object') throw new TypeError('primary connection profile is required');
19
- const credentials = await resolveCredentials(credentialProvider, credentialContext(primary, { identity }));
20
- let primaryConfig = validateProfile({ ...primary, ...credentials }, 'primary');
21
- if (!primaryConfig.user || typeof primaryConfig.password !== 'string') throw new TypeError('primary.user and primary.password are required');
22
- let balancedConfig = balanced ? validateProfile({ ...primaryConfig, ...balanced, ...credentials }, 'balanced') : undefined;
23
- if (credentials.user || credentials.password) {
24
- primaryConfig = validateProfile({ ...primaryConfig, ...credentials }, 'primary');
25
- if (balancedConfig) balancedConfig = validateProfile({ ...balancedConfig, ...credentials }, 'balanced');
26
- }
27
- const validateBundle = (candidate) => validateTokenContext(validateBundleShape(candidate), tokenContext);
28
- let activeBundle = bundle ? validateBundle(bundle) : undefined;
29
- const makeRoute = (route, fallback) => createRouteFactory({ bundle: activeBundle, now, mysqlLib, log, quarantineMs })(route, fallback);
30
- let primaryPool = makeRoute('primary', primaryConfig);
31
- let balancedPool = balancedConfig || activeBundle?.routes?.balanced ? makeRoute('balanced', balancedConfig ?? primaryConfig) : null;
32
- const choose = (sql, options = {}) => options.connection ?? (routeFor(sql, options.route ?? routing) === 'balanced' && balancedPool ? balancedPool : primaryPool);
33
- const metrics = telemetry === true ? createTelemetry({ application: bundle?.application ?? 'default', credentialName: bundle?.credentialName, database: bundle?.database, scopes: bundle?.scopes, now }) : telemetry;
34
- const timed = createTimedOperation({ metrics, now });
35
- const client = {
36
- async query(sql, values, options) { return timed(async () => { const selected = choose(sql, options); try { return await selected.query(sql, values); } catch (error) { const requestedRoute = options?.route ?? routing; if (error.retryable && balancedPool && routeFor(sql, requestedRoute) === 'balanced' && classifyQuery(sql) === 'balanced') { metrics?.record?.({ retry: true }); return balancedPool.query(sql, values); } throw error; } }); },
37
- async execute(sql, values, options) { return timed(async () => choose(sql, options).execute(sql, values)); },
38
- async transaction(callback) { return timed(async () => { 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(); } }); },
39
- 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 }; },
40
- async refresh(nextBundle) { const candidate = validateBundle(nextBundle); if (bundleExpired(candidate, now())) throw new Error('routing bundle is expired'); if (olderVersion(candidate.bundleVersion, activeBundle?.bundleVersion)) return { bundleVersion: activeBundle?.bundleVersion, refreshRequired: bundleNeedsRefresh(activeBundle, now()) }; const previous = [primaryPool, balancedPool]; const credentials = candidate.credentials ?? { username: primaryConfig.user, password: primaryConfig.password }; const writer = candidate.writer ?? candidate.routes.primary?.[0]; const reader = candidate.readers?.[0] ?? candidate.routes.balanced?.[0]; primaryConfig = validateProfile({ ...primaryConfig, host: writer.host, port: writer.port, user: credentials.username, password: credentials.password, database: candidate.database }, 'primary'); balancedConfig = reader ? validateProfile({ ...primaryConfig, host: reader.host, port: reader.port }, 'balanced') : undefined; activeBundle = candidate; primaryPool = makeRoute('primary', primaryConfig); balancedPool = balancedConfig ? makeRoute('balanced', balancedConfig) : null; await Promise.all(previous.filter(Boolean).map((pool) => pool.close())); return { bundleVersion: activeBundle.bundleVersion ?? null, refreshRequired: bundleNeedsRefresh(activeBundle, now()) }; },
41
- async attachRoutingStream(stream) { if (!stream?.connect) throw new TypeError('routing stream is required'); metrics?.start?.(stream); stream.setTelemetry?.(metrics); stream.setOnUpdate?.(async (event) => { const update = event.type === 'routing.update' ? event : event.type === 'routing.resync' ? event.bundle : undefined; if (update && (update.writer || update.routes?.primary?.length)) await client.refresh({ ...activeBundle, ...update, database: update.database ?? activeBundle?.database ?? primaryConfig.database, credentials: update.credentials ?? activeBundle?.credentials, routes: update.routes ?? activeBundle?.routes, bundleVersion: update.bundleVersion ?? update.version ?? activeBundle?.bundleVersion, expiresAt: update.expiresAt ?? activeBundle?.expiresAt ?? new Date(now() + 60000).toISOString() }); if (event.type === 'routing.drain' || event.type === 'routing.shutdown') for (const pool of [primaryPool, balancedPool].filter(Boolean)) (event.type === 'routing.drain' ? pool.drain : pool.drain)(event.node, drainTimeoutMs); if (event.type === 'routing.recovery') for (const pool of [primaryPool, balancedPool].filter(Boolean)) pool.recover(event.node, drainTimeoutMs); }); await stream.connect(); return () => stream.close?.(); },
42
- drain(host, timeoutMs = drainTimeoutMs) { const effectiveTimeout = clientDrainTimeout(timeoutMs); const pools = [primaryPool, balancedPool].filter(Boolean); pools.forEach((pool) => pool.drain(host, effectiveTimeout)); return { host, timeoutMs: effectiveTimeout, wait: () => Promise.all(pools.map((pool) => pool.waitForIdle(effectiveTimeout))), forceClose: () => Promise.all(pools.map((pool) => pool.forceClose(host))) }; },
43
- availability() { const states = this.nodeStates(); const primaryAvailable = states.some((node) => node.route === 'primary' && node.available); return { state: primaryAvailable ? 'available' : 'cluster-unavailable', routes: { primary: primaryAvailable, balanced: states.some((node) => node.route === 'balanced' && node.available) } }; },
44
- nodeStates() { return [primaryPool, balancedPool].filter(Boolean).flatMap((pool) => pool.nodes.map((node) => ({ host: node.host, port: node.port, route: pool === primaryPool ? 'primary' : 'balanced', state: node.state, active: node.active, available: node.available }))); },
45
- setNodeAvailability(route, host, available) { const pool = route === 'balanced' ? balancedPool : primaryPool; pool?.setAvailability(host, available); },
46
- bundle: () => activeBundle,
47
- async close() { metrics?.stop?.(); await Promise.all([primaryPool.close(), balancedPool?.close()]); },
48
- classify: classifyQuery,
49
- telemetry: metrics, config: { primary: redactedProfile(primaryConfig), balanced: balancedConfig && redactedProfile(balancedConfig) }
50
- };
51
- log.debug?.('SQL client created', { balanced: Boolean(balancedPool), routing });
52
- return client;
53
- }
@@ -1,7 +0,0 @@
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 ?? 'localhost', port: env.MYSQL_PRIMARY_PORT ?? env.MYSQL_PORT, user: env.MYSQL_USER, password: env.MYSQL_PASSWORD, database: env.MYSQL_DATABASE, options: { socketPath: 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
- 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
- }
@@ -1,14 +0,0 @@
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
- }
@@ -1,14 +0,0 @@
1
- import { bundleExpired } from '../bundle.mjs';
2
- import { createNodePool, createRoutePool } from '../pools.mjs';
3
- import { bundleProfiles } from './bundle-profiles.mjs';
4
-
5
- const routeProfiles = (bundle, route, fallback, now) => {
6
- if (!bundle) return [fallback];
7
- if (bundleExpired(bundle, now())) return [fallback];
8
- const profiles = bundleProfiles(bundle, route, fallback);
9
- return profiles.length ? profiles : [fallback];
10
- };
11
-
12
- export function createRouteFactory({ bundle, now, mysqlLib, log, quarantineMs }) {
13
- return (route, fallback) => createRoutePool(routeProfiles(bundle, route, fallback, now).map((profile) => createNodePool({ profile, mysqlLib, log, now, quarantineMs })), { preferred: route === 'primary' });
14
- }
@@ -1,15 +0,0 @@
1
- const emptyMetrics = { begin: () => undefined, record: () => {} };
2
-
3
- export function createTimedOperation({ metrics = emptyMetrics, now = () => Date.now() } = {}) {
4
- return async function timed(operation) {
5
- const started = metrics.begin();
6
- try {
7
- const result = await operation();
8
- metrics.record({ latencyMs: started === undefined ? 0 : now() - started });
9
- return result;
10
- } catch (error) {
11
- metrics.record({ latencyMs: started === undefined ? 0 : now() - started, failed: true });
12
- throw error;
13
- }
14
- };
15
- }
package/src/config.mjs DELETED
@@ -1,31 +0,0 @@
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
- }
@@ -1,12 +0,0 @@
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
- }
@@ -1,23 +0,0 @@
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
- if (driverOptions.socketPath === undefined) delete driverOptions.socketPath;
6
- const pool = mysqlLib.createPool({ host: profile.host, port: profile.port, user: profile.user, password: profile.password, database: profile.database, waitForConnections: true, ...driverOptions });
7
- const sessionStatements = profile.options?.sessionStatements ?? [];
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(); } };
9
- let failures = 0; let unavailableUntil = 0; let forcedUnavailable = false; let lifecycle = 'ready'; let active = 0; let idleResolve; let drainTimer;
10
- const begin = () => { active += 1; };
11
- const end = () => { active = Math.max(0, active - 1); if (!active) idleResolve?.(); };
12
- const run = async (work) => { begin(); try { return await work(); } finally { end(); } };
13
- const drain = (timeoutMs = 45000) => { lifecycle = 'draining'; forcedUnavailable = true; unavailableUntil = Number.POSITIVE_INFINITY; clearTimeout(drainTimer); drainTimer = setTimeout(() => { void forceClose(); }, timeoutMs); drainTimer.unref?.(); return { state: lifecycle, active }; };
14
- const waitForIdle = async (timeoutMs = 45000) => { if (!active) return true; await Promise.race([new Promise((resolve) => { idleResolve = resolve; }), new Promise((resolve) => setTimeout(resolve, timeoutMs))]); idleResolve = undefined; return active === 0; };
15
- const forceClose = async () => { lifecycle = 'unavailable'; await pool.end(); };
16
- const recover = () => { clearTimeout(drainTimer); drainTimer = undefined; lifecycle = 'recovering'; forcedUnavailable = false; unavailableUntil = 0; lifecycle = 'ready'; return lifecycle; };
17
- return { host: profile.host, port: profile.port, weight: Number(profile.weight ?? 100), get available() { return lifecycle === 'ready' && !forcedUnavailable && now() >= unavailableUntil; }, set available(value) { if (value) recover(); else drain(); }, get failures() { return failures; }, get state() { return lifecycle; }, get active() { return active; }, drain, recover, waitForIdle, forceClose,
18
- async query(sql, values) { if (!this.available) throw new Error(`SQL node ${profile.host} is unavailable`); try { const result = await run(() => sessionStatements.length ? withConnection((connection, statement, params) => connection.query(statement, params), sql, values) : 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); } },
19
- async execute(sql, values) { if (!this.available) throw new Error(`SQL node ${profile.host} is unavailable`); try { const result = await run(() => sessionStatements.length ? withConnection((connection, statement, params) => connection.execute(statement, params), sql, values) : pool.execute(sql, values)); failures = 0; return result; } catch (error) { if (connectionFailure(error)) { failures += 1; unavailableUntil = now() + quarantineMs; } throw asSqlError(error); } },
20
- async getConnection() { if (!this.available) throw new Error(`SQL node ${profile.host} is unavailable`); try { const connection = await pool.getConnection(); begin(); const release = connection.release.bind(connection); let released = false; connection.release = () => { if (released) return; released = true; end(); release(); }; return connection; } catch (error) { if (connectionFailure(error)) unavailableUntil = now() + quarantineMs; throw asSqlError(error); } },
21
- 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); } },
22
- async close() { clearTimeout(drainTimer); lifecycle = 'unavailable'; await pool.end(); } };
23
- }
@@ -1,47 +0,0 @@
1
- import { ClusterUnavailableError, ServerUnavailableError } from '../errors.mjs';
2
-
3
- export function createRoutePool(nodes, { preferred = false, unavailableError = nodes.length === 1 ? ServerUnavailableError : ClusterUnavailableError } = {}) {
4
- let cursor = 0;
5
- const candidates = () => nodes.filter((node) => node.available);
6
- const choose = () => {
7
- const available = candidates();
8
- if (!available.length) throw new unavailableError('no eligible SQL nodes available');
9
- if (preferred) return available[0];
10
- const total = available.reduce((sum, node) => sum + Math.max(0, node.weight), 0);
11
- if (!total) return available[cursor++ % available.length];
12
- let target = cursor++ % total;
13
- let selected = available[available.length - 1];
14
- for (const node of available) {
15
- const weight = Math.max(0, node.weight);
16
- if (target < weight) {
17
- selected = node;
18
- break;
19
- }
20
- target -= weight;
21
- }
22
- return selected;
23
- };
24
- const setAvailability = (host, available) => {
25
- for (const node of nodes) if (node.host === host) node.available = available;
26
- };
27
- const lifecycle = (host, state) => nodes.filter((node) => node.host === host).map((node) => state === 'draining' ? node.drain() : node.recover());
28
- const drain = (host, timeoutMs) => { for (const node of nodes.filter((value) => value.host === host)) node.drain?.(timeoutMs); return lifecycle(host, 'draining'); };
29
- const recover = (host) => lifecycle(host, 'recovering');
30
- const waitForIdle = (timeoutMs) => Promise.all(nodes.map((node) => node.waitForIdle?.(timeoutMs) ?? true));
31
- const forceClose = (host) => Promise.all(nodes.filter((node) => !host || node.host === host).map((node) => node.forceClose?.() ?? node.close()));
32
- const query = (sql, values) => choose().query(sql, values);
33
- const execute = (sql, values) => choose().execute(sql, values);
34
- const health = async () => {
35
- const results = [];
36
- for (const node of nodes) {
37
- try {
38
- results.push(await node.health());
39
- } catch (error) {
40
- results.push({ ok: false, host: node.host, port: node.port, error: error.message });
41
- }
42
- }
43
- return results;
44
- };
45
- const close = async () => Promise.all(nodes.map((node) => node.close()));
46
- return { nodes, choose, setAvailability, drain, recover, waitForIdle, forceClose, query, execute, health, close };
47
- }
package/src/pools.mjs DELETED
@@ -1,3 +0,0 @@
1
- export { createNodePool } from './pools/node-pool.mjs';
2
- export { createRoutePool } from './pools/route-pool.mjs';
3
- /* istanbul ignore file -- barrel exports only. */
package/src/routing.mjs DELETED
@@ -1,15 +0,0 @@
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
- }