@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 +27 -118
- package/RELEASE_NOTES.md +33 -0
- package/contracts/managed-client.md +36 -0
- package/contracts/routing-bundle.fixture.json +17 -0
- package/contracts/routing-bundle.schema.json +9 -0
- package/examples/README.md +4 -6
- package/examples/basic-client.mjs +5 -11
- package/package.json +9 -8
- package/src/bundle.mjs +18 -3
- package/src/index.d.ts +14 -114
- package/src/index.mjs +2 -14
- package/src/client/authorization-context.mjs +0 -9
- package/src/client/bundle-profiles.mjs +0 -7
- package/src/client/create-db.mjs +0 -53
- package/src/client/from-bundle.mjs +0 -14
- package/src/client/managed.mjs +0 -15
- package/src/client/route-factory.mjs +0 -14
- package/src/client/telemetry-wrapper.mjs +0 -15
- package/src/config.mjs +0 -31
- package/src/credential-provider.mjs +0 -12
- package/src/pools/node-pool.mjs +0 -23
- package/src/pools/route-pool.mjs +0 -47
- package/src/pools.mjs +0 -3
- package/src/routing.mjs +0 -15
package/README.md
CHANGED
|
@@ -1,123 +1,32 @@
|
|
|
1
1
|
# @eliware/elera-lib
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
client
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
}
|
package/examples/README.md
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
# Elera library example
|
|
2
2
|
|
|
3
|
-
`basic-client.mjs`
|
|
4
|
-
|
|
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
|
-
|
|
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 {
|
|
1
|
+
import { createTelemetry, validateBundle } from '@eliware/elera-lib';
|
|
2
2
|
|
|
3
|
-
//
|
|
4
|
-
const
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"bearer-token",
|
|
11
|
+
"protocol",
|
|
12
|
+
"contracts",
|
|
14
13
|
"routing",
|
|
15
14
|
"failover",
|
|
16
15
|
"telemetry",
|
|
17
16
|
"websocket",
|
|
18
|
-
"
|
|
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 =
|
|
8
|
-
const 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
|
-
|
|
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
|
-
|
|
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?:
|
|
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
|
|
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
|
|
119
|
-
export
|
|
120
|
-
export
|
|
121
|
-
export
|
|
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
|
-
}
|
package/src/client/create-db.mjs
DELETED
|
@@ -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
|
-
}
|
package/src/client/managed.mjs
DELETED
|
@@ -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
|
-
}
|
package/src/pools/node-pool.mjs
DELETED
|
@@ -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
|
-
}
|
package/src/pools/route-pool.mjs
DELETED
|
@@ -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
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
|
-
}
|