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