@eliware/elera-lib 0.2.0 → 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,123 +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 HAProxy, backup, or
5
- GitOps policy.
6
-
7
- `primary` is the designated write path. `balanced` is an optional read path;
8
- automatic routing sends only conservative, single-statement read queries to it.
9
- Transactions always use `primary`.
10
-
11
- ```js
12
- import { createDb } from '@eliware/elera-lib';
13
- const db = await createDb();
14
- await db.query('SELECT 1');
15
- await db.close();
16
- ```
17
-
18
- Managed applications configure only `ELERA_API_ENDPOINT` and
19
- `ELERA_API_TOKEN`. Call `createDb({ endpoint, token })` explicitly or omit
20
- either value when it is available in the process environment.
21
-
22
- See `examples/basic-client.mjs` for a complete consumer example using only the
23
- public package API. Its usage notes are in `examples/README.md`. The managed
24
- client contract is defined in `contracts/managed-client.md`; it owns initial
25
- bundle retrieval, credential materialization, and routing-stream setup.
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 may also carry an explicit `writer`, ordered
30
- `failover`, and `readers` assignment. The bundle carries `database`,
31
- `identity`, optional `application`, `credentialName`, `scopes`, and
32
- `credentials`, and `expiresAt`; `validateBundle` rejects
33
- expired, malformed, duplicated, or conflicting route data. The checked-in
34
- contract fixture documents the supervisor-facing wire representation
35
- separately.
36
-
37
- The implementation accepts optional routing bundles and injected credential
38
- providers, maintains bounded pools per route, supports ordered writer/reader
39
- candidates, bundle refresh, and quarantine of unhealthy nodes. The WebSocket
40
- routing-event transport is implemented as a generic adapter. Nodes can be
41
- immediately excluded or re-admitted with `client.setNodeAvailability(route,
42
- host, available)`. These are generic client capabilities: the
43
- library does not know about supervisors, Elera, HAProxy, GitOps, backups, or
44
- CLI commands. Applications provide those integrations through ordinary
45
- configuration and callbacks.
46
-
47
- An application-scoped token should resolve to one application, database, and
48
- credential context. The library does not select databases or credentials from
49
- request arguments. Callers that already have that authorization context may
50
- pass `tokenContext` to `createDb`; bundle creation and refresh then reject
51
- cross-database, identity, credential, or scope mismatches.
52
-
53
- When a route node is drained, the client immediately stops assigning new work
54
- to that node while existing operations continue. The drain window defaults to
55
- 45 seconds and is capped at 45 seconds; remaining pool connections are then
56
- force-closed. `client.drain(host)` returns `wait()` and `forceClose()`
57
- operations, while `client.nodeStates()` exposes lifecycle and active-operation
58
- state. Only conservative, single-statement reads are eligible for automatic
59
- retry after a connection failure; uncertain writes are never retried
60
- automatically.
61
-
62
- `client.availability()` reports whether a primary route is usable. It returns
63
- `state: 'cluster-unavailable'` when every primary candidate is draining or
64
- unavailable; the `routes` fields report primary and balanced availability
65
- independently. A single-node route fails with the exported
66
- `ServerUnavailableError` using code `SERVER_UNAVAILABLE`; a multi-node route
67
- with no eligible candidates fails with `ClusterUnavailableError` using code
68
- `CLUSTER_UNAVAILABLE`.
69
-
70
- When an attached routing stream receives a `routing.shutdown` event, the
71
- client drains the identified node, performs a REST bundle resynchronization,
72
- and closes the retiring WebSocket with restart code `1012`. If the event
73
- contains `loadBalancerEndpoint`, that endpoint replaces the current endpoint
74
- before resynchronization and reconnect. `reconnectDeadlineMs` bounds the
75
- planned reconnect window; after it expires, no new reconnect is scheduled.
76
- Reconnects, failovers, and measured reconnect delay are included in telemetry.
77
- If the WebSocket remains unavailable before the deadline, REST
78
- resynchronization and bounded reconnect backoff continue until the deadline or
79
- until the caller closes the stream.
80
-
81
- Routing events are validated before application. Shutdown events may include
82
- `node`, `reason`, `reconnect`, `reconnectDeadlineMs`, and
83
- `loadBalancerEndpoint`; invalid event fields are reported through `onError` and
84
- do not change routing state. `routing.update` replaces the writer and reader
85
- pools atomically, while `routing.drain` excludes only the named node from new
86
- work and allows active operations to finish.
87
-
88
- The public client intentionally exposes SQL operations, health, routing,
89
- lifecycle, and optional routing-event synchronization methods. REST and
90
- WebSocket transports are adapters, not supervisor or CLI policy. Underlying
91
- `mysql2` pools and driver objects remain internal implementation details.
92
-
93
- For maintenance workflows, `createQuiesceController` provides a generic
94
- connection-admission drain and `createSqlVerifier` provides generic connectivity,
95
- schema, account, and grant checks. Neither API transports or orchestrates dump
96
- contents. The stream reports `websocket`, `rest`, or `disconnected` mode so
97
- callers can observe transport health without implementing transport policy.
98
-
99
- Applications may opt into generic in-memory telemetry with
100
- `createDb({ ..., telemetry: true })`. Query, execute, and transaction counts,
101
- failures, retries, in-flight work, and latency are exposed through
102
- `client.telemetry` and sent over an attached routing stream once per second.
103
- Telemetry is observational only; it does not carry SQL or credentials.
104
- The snapshot may include the application, database, credential name, and
105
- scopes associated with the already-authorized client, plus reconnect, failover,
106
- and cumulative reconnect-delay counters. It never includes bearer tokens or
107
- passwords.
108
-
109
- `createMaterializer` supports bounded plaintext use for a caller-provided
110
- operation. It creates a mode-restricted temporary file and removes its entire
111
- temporary directory in a `finally` block; this limits lifetime and cleanup but
112
- does not hide plaintext from the caller. The library does not persist secrets,
113
- age keys, or supervisor-specific artifact metadata.
114
-
115
- The package exports the managed SQL client and internal bundle composition
116
- primitives, query classification and route selection, routing-bundle validation, generic REST
117
- and WebSocket routing adapters, SQL administration and verification helpers,
118
- and lifecycle helpers for quiescing and temporary materialization. These
119
- helpers remain policy-neutral and do not provision users, manage clusters, or
120
- 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.
121
30
 
122
31
  ## Development
123
32
 
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,38 @@
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
+
3
36
  ## 0.2.0 — Managed endpoint and token client
4
37
 
5
38
  ### Breaking changes
@@ -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 managed consumer application. It imports only
4
- the public `@eliware/elera-lib` package API, opens a SQL client from the Elera
5
- endpoint and application token, performs a health check and query, and closes
6
- the 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 with:
6
+ Run it with a complete bundle:
9
7
 
10
- ELERA_API_ENDPOINT=http://supervisor-or-load-balancer:8080 ELERA_API_TOKEN=application-token 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
10
  The example is infrastructure-neutral. It contains no Docker, Kubernetes,
13
11
  Supervisor, CLI, Galera, or test-lab setup.
@@ -1,13 +1,7 @@
1
- import { createDb } from '@eliware/elera-lib';
1
+ import { createTelemetry, validateBundle } from '@eliware/elera-lib';
2
2
 
3
- // Set ELERA_API_ENDPOINT and ELERA_API_TOKEN before running this example.
4
- const db = await createDb();
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 });
5
6
 
6
- try {
7
- const health = await db.health('primary');
8
- if (!health.ok) throw new Error('primary SQL route is not healthy');
9
- const [rows] = await db.query('SELECT 1 AS healthy');
10
- console.log(JSON.stringify({ health, rows }));
11
- } finally {
12
- await db.close();
13
- }
7
+ console.log(JSON.stringify({ bundleVersion: bundle.bundleVersion, telemetry: telemetry.snapshot() }));
package/package.json CHANGED
@@ -1,21 +1,21 @@
1
1
  {
2
2
  "name": "@eliware/elera-lib",
3
- "version": "0.2.0",
4
- "description": "Managed MySQL and MariaDB client using Elera endpoint and bearer-token routing",
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
10
  "galera",
11
- "sql",
12
- "database",
13
- "bearer-token",
11
+ "protocol",
12
+ "contracts",
14
13
  "routing",
15
14
  "failover",
16
15
  "telemetry",
17
16
  "websocket",
18
- "connection-pool"
17
+ "protocol-contracts",
18
+ "mysql-client"
19
19
  ],
20
20
  "repository": {
21
21
  "type": "git",
@@ -27,6 +27,7 @@
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "type": "module",
30
+ "sideEffects": false,
30
31
  "engines": {
31
32
  "node": ">=26"
32
33
  },
@@ -38,6 +39,7 @@
38
39
  },
39
40
  "files": [
40
41
  "src",
42
+ "contracts",
41
43
  "examples",
42
44
  "README.md",
43
45
  "RELEASE_NOTES.md",
@@ -56,8 +58,7 @@
56
58
  },
57
59
  "dependencies": {
58
60
  "@eliware/common": "^2.0.0",
59
- "@eliware/snowflake": "^2.0.0",
60
- "mysql2": "^3.24.2"
61
+ "@eliware/snowflake": "^2.0.0"
61
62
  },
62
63
  "devDependencies": {
63
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,121 +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 RoutingPorts { sql?: number | string; http?: number | string; ws?: number | string; [name: string]: number | string | undefined; }
15
- export interface NodeIdentity { id?: string; name?: string; address?: string; ports?: RoutingPorts; }
16
- export interface RoutingBundle {
17
- apiVersion?: string;
18
- applicationId?: string;
19
- databaseId?: string;
20
- identityId?: string;
21
- database?: string;
22
- identity?: string;
23
- application?: string;
24
- credentialName?: string;
25
- scopes?: string[];
26
- credentials?: { username?: string; password?: string };
27
- bundleVersion?: number | string;
28
- expiresAt: string;
29
- refreshAfter?: string;
30
- routes: { primary?: RoutingNode[]; balanced?: RoutingNode[] };
31
- writer?: WriterAssignment;
32
- failover?: WriterAssignment[];
33
- readers?: WriterAssignment[];
34
- nodeIdentity?: NodeIdentity;
35
- ports?: RoutingPorts;
36
- }
37
- /** Application-facing configuration for the managed Elera client.
38
- * Managed applications provide only the supervisor/load-balancer endpoint and
39
- * their application-scoped bearer token. Bundle retrieval, credentials, and
40
- * SQL routing are library responsibilities.
41
- */
42
- export interface ManagedClientOptions {
43
- endpoint?: string;
44
- token?: string;
45
- mysqlLib?: unknown;
46
- log?: unknown;
47
- routing?: 'auto' | 'primary' | 'balanced';
48
- quarantineMs?: number;
49
- drainTimeoutMs?: number;
50
- now?: () => number;
51
- telemetry?: true | Telemetry;
52
- }
53
- export interface CredentialProviderResult { user: string; password: string; }
54
- export type CredentialProvider = (context: { database: string; identity: string | null; route: string }) => Promise<CredentialProviderResult> | CredentialProviderResult;
55
- export type QueryFunction = (sql: string, values?: unknown) => Promise<any>;
56
- 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>; }
57
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; }
58
- 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; }
59
-
60
- export interface DbClient {
61
- query(sql: string, values?: unknown, options?: DbOptions): Promise<unknown>;
62
- execute(sql: string, values?: unknown, options?: DbOptions): Promise<unknown>;
63
- transaction<T>(callback: (transaction: Pick<DbClient, 'query' | 'execute'>) => Promise<T>): Promise<T>;
64
- health(route?: 'primary' | 'balanced'): Promise<{ ok: boolean; route: string; latencyMs: number; telemetry?: TelemetrySnapshot }>;
65
- close(): Promise<void>;
66
- refresh(bundle: RoutingBundle): Promise<{ bundleVersion: number | string | null; refreshRequired: boolean }>;
67
- bundle(): RoutingBundle | undefined;
68
- classify(sql: string): 'primary' | 'balanced';
69
- attachRoutingStream(stream: RoutingStream): Promise<() => void>;
70
- setNodeAvailability(route: 'primary' | 'balanced', host: string, available: boolean): void;
71
- availability(): { state: 'available' | 'cluster-unavailable'; routes: { primary: boolean; balanced: boolean } };
72
- drain(host: string, timeoutMs?: number): { host: string; timeoutMs: number; wait(): Promise<unknown[]>; forceClose(): Promise<unknown[]> };
73
- nodeStates(): Array<{ host: string; port: number; route: 'primary' | 'balanced'; state: 'ready' | 'draining' | 'unavailable' | 'recovering'; active: number; available: boolean }>;
74
- config: { primary: ConnectionProfile; balanced?: ConnectionProfile };
75
- telemetry?: Telemetry;
76
- }
77
-
78
- export interface RoutingStream {
79
- connect(): Promise<void>;
80
- setOnUpdate(handler: (event: unknown) => void | Promise<void>): void;
81
- close(): void;
82
- sendTelemetry(payload: unknown): void;
83
- setTelemetry?(telemetry?: Pick<Telemetry, 'recordReconnect'>): void;
84
- state(): { connected: boolean; mode: 'websocket' | 'rest' | 'disconnected'; expectedVersion: number | string; endpoint: string; reconnectDeadlineAt?: number };
85
- }
86
-
87
- 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 };
88
- export function validateRoutingEvent(event: unknown): RoutingEvent;
89
-
90
- 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>;
91
- export function profilesFromBundle(bundle: RoutingBundle): { primary: ConnectionProfile; balanced?: ConnectionProfile };
92
- 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>;
93
- export function createDb(options?: ManagedClientOptions & { fetchImpl?: typeof fetch; fetchPath?: string; WebSocketImpl?: typeof WebSocket }): Promise<DbClient>;
94
- export function validateTokenContext(bundle: RoutingBundle, tokenContext?: { application?: string; database?: string; credentialName?: string; identity?: string; scopes?: string[] }): RoutingBundle;
95
- export function classifyQuery(sql: unknown): 'primary' | 'balanced';
96
- export function routeFor(sql: unknown, requested?: 'auto' | 'primary' | 'balanced'): 'primary' | 'balanced';
97
- export function validateProfile(profile: ConnectionProfile, name?: string): ConnectionProfile;
98
- export function redactedProfile(profile: ConnectionProfile): ConnectionProfile;
99
- export class SqlClientError extends Error { code?: string; retryable?: boolean; cause?: unknown; }
100
- export class ClusterUnavailableError extends SqlClientError {}
101
- export class ServerUnavailableError extends SqlClientError {}
102
- export function classifyError(error: unknown): { retryable: boolean; code?: string };
103
- export function asSqlError(error: unknown): SqlClientError;
4
+ export interface Telemetry { snapshot(): TelemetrySnapshot; begin(): number; record(event?: unknown): void; recordReconnect(event?: unknown): void; }
104
5
  export function validateBundle(bundle: RoutingBundle): RoutingBundle;
105
6
  export function bundleExpired(bundle: RoutingBundle, now?: number): boolean;
106
7
  export function bundleNeedsRefresh(bundle: RoutingBundle, now?: number): boolean;
107
- export function createAdminSql(options: { query: QueryFunction }): { transaction<T>(work: (context: { query: QueryFunction }) => Promise<T>): Promise<T>; migration(statements?: string[]): Promise<unknown> };
108
- export function createMigrationRunner(options: { query: QueryFunction; migrations?: Array<{ version: number; name: string; statements: string[] }> }): { status(): Promise<{ applied: unknown[] }>; migrate(): Promise<unknown> };
109
- export function selectRouteNodes(options: { bundle: RoutingBundle; route?: 'primary' | 'balanced'; now?: number }): RoutingNode[];
110
- 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;
111
- export const DEFAULT_BUNDLE_PATH: '/api/v1/routing/bundle';
112
- export function fetchRoutingBundle(options: { endpoint: string; token: string; path?: string; fetchImpl?: typeof fetch; signal?: AbortSignal }): Promise<RoutingBundle>;
113
- export function writerAssignment(bundle: RoutingBundle): WriterAssignment;
114
- export function failoverNodes(bundle: RoutingBundle): WriterAssignment[];
8
+ export function validateRoutingEvent(event: unknown): unknown;
115
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[];
116
14
  export const CLIENT_DRAIN_TIMEOUT_MS: 45000;
117
15
  export function clientDrainTimeout(timeoutMs?: number): number;
118
- 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> };
119
- 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> };
120
- export function createMaterializer(options?: Record<string, unknown>): unknown;
121
- 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/managed.mjs';
2
- export { validateTokenContext } from './client/authorization-context.mjs';
3
- export { createDbFromBundle, profilesFromBundle } from './client/from-bundle.mjs';
4
- export { classifyQuery, routeFor } from './routing.mjs';
5
- export { validateProfile, redactedProfile } from './config.mjs';
6
1
  export { SqlClientError, ClusterUnavailableError, ServerUnavailableError, classifyError, asSqlError } from './errors.mjs';
7
2
  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 { fetchRoutingBundle, DEFAULT_BUNDLE_PATH } from './routing/bundle-fetcher.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';
@@ -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]; activeBundle = candidate; if (writer) { primaryConfig = validateProfile({ ...primaryConfig, host: writer.host, port: writer.port, user: credentials.username, password: credentials.password, database: candidate.database }, 'primary'); primaryPool = makeRoute('primary', primaryConfig); } else { for (const node of primaryPool.nodes) node.drain?.(drainTimeoutMs); } if (reader) { balancedConfig = validateProfile({ ...primaryConfig, host: reader.host, port: reader.port }, 'balanced'); balancedPool = makeRoute('balanced', balancedConfig); } else if (balancedPool) { for (const node of balancedPool.nodes) node.drain?.(drainTimeoutMs); } await Promise.all(previous.filter(Boolean).filter((pool) => pool !== primaryPool && pool !== balancedPool).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) 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)) 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,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,15 +0,0 @@
1
- import { createDbFromBundle } from './from-bundle.mjs';
2
- import { fetchRoutingBundle } from '../routing/bundle-fetcher.mjs';
3
- import { createRoutingStream } from '../routing/stream-client.mjs';
4
-
5
- export async function createDb({ endpoint = process.env.ELERA_API_ENDPOINT, token = process.env.ELERA_API_TOKEN, fetchImpl = globalThis.fetch, fetchPath, WebSocketImpl = globalThis.WebSocket, ...options } = {}) {
6
- const fetchBundle = () => fetchRoutingBundle({ endpoint, token, fetchImpl, path: fetchPath });
7
- const bundle = await fetchBundle();
8
- const stream = createRoutingStream({ endpoint, token, fetchBundle, WebSocketImpl, ...options });
9
- const tokenContext = { application: bundle.application, database: bundle.database, credentialName: bundle.credentialName, identity: bundle.identity, scopes: bundle.scopes };
10
- const client = await createDbFromBundle({ bundle, tokenContext, ...options });
11
- const detach = await client.attachRoutingStream(stream);
12
- const close = client.close.bind(client);
13
- client.close = async () => { await detach?.(); await close(); };
14
- return client;
15
- }
@@ -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
- }