@eliware/elera-lib 0.1.11 → 0.2.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 +16 -22
- package/RELEASE_NOTES.md +39 -2
- package/examples/README.md +8 -8
- package/examples/basic-client.mjs +3 -4
- package/package.json +4 -2
- package/src/client/create-db.mjs +2 -2
- package/src/client/from-bundle.mjs +1 -1
- package/src/client/managed.mjs +15 -0
- package/src/index.d.ts +26 -1
- package/src/index.mjs +2 -2
- package/src/routing/bundle-fetcher.mjs +20 -0
- package/src/routing/stream-client.mjs +6 -4
- package/src/client/environment.mjs +0 -7
package/README.md
CHANGED
|
@@ -1,34 +1,28 @@
|
|
|
1
1
|
# @eliware/elera-lib
|
|
2
2
|
|
|
3
3
|
The alternative SQL client for Eliware applications. It provides generic
|
|
4
|
-
primary/balanced MySQL or MariaDB routing without embedding
|
|
5
|
-
|
|
6
|
-
existing package is intentionally unchanged. The current package version is
|
|
7
|
-
0.1.11.
|
|
4
|
+
primary/balanced MySQL or MariaDB routing without embedding HAProxy, backup, or
|
|
5
|
+
GitOps policy.
|
|
8
6
|
|
|
9
|
-
`primary` is the
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
`primary` is the designated write path. `balanced` is an optional read path;
|
|
8
|
+
automatic routing sends only conservative, single-statement read queries to it.
|
|
9
|
+
Transactions always use `primary`.
|
|
12
10
|
|
|
13
11
|
```js
|
|
14
|
-
import {
|
|
15
|
-
const db = await
|
|
12
|
+
import { createDb } from '@eliware/elera-lib';
|
|
13
|
+
const db = await createDb();
|
|
16
14
|
await db.query('SELECT 1');
|
|
17
15
|
await db.close();
|
|
18
16
|
```
|
|
19
17
|
|
|
20
|
-
|
|
21
|
-
`
|
|
22
|
-
|
|
23
|
-
`MYSQL_DATABASE`. `MYSQL_SOCKET` optionally selects a Unix-domain socket for
|
|
24
|
-
the primary connection, which is useful for local socket-authenticated MariaDB
|
|
25
|
-
accounts. Pool settings may be supplied with `MYSQL_CONNECT_TIMEOUT`,
|
|
26
|
-
`MYSQL_ACQUIRE_TIMEOUT`, `MYSQL_CONNECTION_LIMIT`, `MYSQL_QUEUE_LIMIT`, and
|
|
27
|
-
`MYSQL_SSL`. Configure primary and balanced routes explicitly; applications
|
|
28
|
-
should not rely on ambiguous single-endpoint aliases.
|
|
18
|
+
Managed applications configure only `ELERA_API_ENDPOINT` and
|
|
19
|
+
`ELERA_API_TOKEN`. Call `createDb({ endpoint, token })` explicitly or omit
|
|
20
|
+
either value when it is available in the process environment.
|
|
29
21
|
|
|
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
|
|
22
|
+
See `examples/basic-client.mjs` for a complete consumer example using only the
|
|
23
|
+
public package API. Its usage notes are in `examples/README.md`. The managed
|
|
24
|
+
client contract is defined in `contracts/managed-client.md`; it owns initial
|
|
25
|
+
bundle retrieval, credential materialization, and routing-stream setup.
|
|
32
26
|
|
|
33
27
|
Routing bundles passed to `createDbFromBundle` use the normalized shape
|
|
34
28
|
`routes.primary` and `routes.balanced`, each containing ordered `{ host, port,
|
|
@@ -118,8 +112,8 @@ temporary directory in a `finally` block; this limits lifetime and cleanup but
|
|
|
118
112
|
does not hide plaintext from the caller. The library does not persist secrets,
|
|
119
113
|
age keys, or supervisor-specific artifact metadata.
|
|
120
114
|
|
|
121
|
-
The package exports the SQL client and
|
|
122
|
-
classification and route selection, routing-bundle validation, generic REST
|
|
115
|
+
The package exports the managed SQL client and internal bundle composition
|
|
116
|
+
primitives, query classification and route selection, routing-bundle validation, generic REST
|
|
123
117
|
and WebSocket routing adapters, SQL administration and verification helpers,
|
|
124
118
|
and lifecycle helpers for quiescing and temporary materialization. These
|
|
125
119
|
helpers remain policy-neutral and do not provision users, manage clusters, or
|
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,5 +1,43 @@
|
|
|
1
1
|
# Release notes
|
|
2
2
|
|
|
3
|
+
## 0.2.0 — Managed endpoint and token client
|
|
4
|
+
|
|
5
|
+
### Breaking changes
|
|
6
|
+
|
|
7
|
+
- Adds a new managed-client workflow; applications using it no longer provide
|
|
8
|
+
SQL hosts, database names, usernames, passwords, or routing profiles.
|
|
9
|
+
- Managed bundle updates are now constrained to the authorization context
|
|
10
|
+
established by the initial bundle.
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Defines the managed application contract using only
|
|
15
|
+
`ELERA_API_ENDPOINT` and `ELERA_API_TOKEN`.
|
|
16
|
+
- Adds authenticated `fetchRoutingBundle` REST retrieval with response
|
|
17
|
+
validation and a configurable bundle path.
|
|
18
|
+
- Makes `createDb()` the sole application-facing managed workflow. It acquires the
|
|
19
|
+
initial bundle and attaches the routing stream automatically, using explicit
|
|
20
|
+
options or `ELERA_API_ENDPOINT` and `ELERA_API_TOKEN` by default.
|
|
21
|
+
- Establishes application, database, identity, credential, and scope context
|
|
22
|
+
from the initial bundle and rejects cross-context updates.
|
|
23
|
+
- Adds explicit routing-bundle metadata for application, database, and
|
|
24
|
+
identity IDs, node identity, and service ports.
|
|
25
|
+
- Handles routing updates that temporarily remove all eligible writers or
|
|
26
|
+
readers without dereferencing missing routes.
|
|
27
|
+
- Prevents concurrent stream connections and reconnects after a shutdown
|
|
28
|
+
deadline.
|
|
29
|
+
|
|
30
|
+
### API boundary
|
|
31
|
+
|
|
32
|
+
- The managed factory is the application-facing workflow and accepts only the
|
|
33
|
+
endpoint and application token.
|
|
34
|
+
|
|
35
|
+
### Verification
|
|
36
|
+
|
|
37
|
+
- Adds focused bundle-fetcher, managed-client, lifecycle, and
|
|
38
|
+
authorization-boundary tests.
|
|
39
|
+
- Static syntax, schema, and diff validation pass for the committed changes.
|
|
40
|
+
|
|
3
41
|
## 0.1.11 — Public runtime declarations
|
|
4
42
|
|
|
5
43
|
### Changed
|
|
@@ -306,10 +344,9 @@ GitOps, backup, or CLI policy.
|
|
|
306
344
|
policy.
|
|
307
345
|
- Requires applications to provide their own credential and routing adapters.
|
|
308
346
|
|
|
309
|
-
###
|
|
347
|
+
### Validation
|
|
310
348
|
|
|
311
349
|
- ESM package targeting Node.js 26 or newer.
|
|
312
350
|
- TypeScript declarations are included with the package.
|
|
313
|
-
- Existing `@eliware/mysql` is not modified or required.
|
|
314
351
|
- Strict test coverage is maintained at 100% statements, branches, functions,
|
|
315
352
|
and lines with zero lint warnings.
|
package/examples/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# Elera library example
|
|
2
2
|
|
|
3
|
-
basic-client.mjs is a minimal consumer application. It imports only
|
|
4
|
-
public
|
|
5
|
-
|
|
6
|
-
client in a finally block.
|
|
3
|
+
`basic-client.mjs` is a minimal managed consumer application. It imports only
|
|
4
|
+
the public `@eliware/elera-lib` package API, opens a SQL client from the Elera
|
|
5
|
+
endpoint and application token, performs a health check and query, and closes
|
|
6
|
+
the client in a `finally` block.
|
|
7
7
|
|
|
8
|
-
Run it
|
|
8
|
+
Run it with:
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
ELERA_API_ENDPOINT=http://supervisor-or-load-balancer:8080 ELERA_API_TOKEN=application-token node examples/basic-client.mjs
|
|
11
11
|
|
|
12
|
-
The example is
|
|
13
|
-
|
|
12
|
+
The example is infrastructure-neutral. It contains no Docker, Kubernetes,
|
|
13
|
+
Supervisor, CLI, Galera, or test-lab setup.
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createDb } from '@eliware/elera-lib';
|
|
2
2
|
|
|
3
|
-
// Set
|
|
4
|
-
|
|
5
|
-
const db = await createDbFromEnvironment();
|
|
3
|
+
// Set ELERA_API_ENDPOINT and ELERA_API_TOKEN before running this example.
|
|
4
|
+
const db = await createDb();
|
|
6
5
|
|
|
7
6
|
try {
|
|
8
7
|
const health = await db.health('primary');
|
package/package.json
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eliware/elera-lib",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Managed MySQL and MariaDB client using Elera endpoint and bearer-token routing",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"eliware",
|
|
7
7
|
"elera",
|
|
8
8
|
"mysql",
|
|
9
9
|
"mariadb",
|
|
10
|
+
"galera",
|
|
10
11
|
"sql",
|
|
11
12
|
"database",
|
|
13
|
+
"bearer-token",
|
|
12
14
|
"routing",
|
|
13
15
|
"failover",
|
|
14
16
|
"telemetry",
|
package/src/client/create-db.mjs
CHANGED
|
@@ -37,8 +37,8 @@ export async function createDb({ primary, balanced, bundle, credentialProvider,
|
|
|
37
37
|
async execute(sql, values, options) { return timed(async () => choose(sql, options).execute(sql, values)); },
|
|
38
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
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');
|
|
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
|
|
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
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
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
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 }))); },
|
|
@@ -9,6 +9,6 @@ export function profilesFromBundle(bundle) {
|
|
|
9
9
|
|
|
10
10
|
export async function createDbFromBundle({ bundle, createClient, ...options } = {}) {
|
|
11
11
|
const profiles = profilesFromBundle(bundle);
|
|
12
|
-
|
|
12
|
+
const factory = createClient ?? (await import('./create-db.mjs')).createDb;
|
|
13
13
|
return factory({ ...options, ...profiles, bundle, identity: bundle.identity });
|
|
14
14
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
}
|
package/src/index.d.ts
CHANGED
|
@@ -11,8 +11,13 @@ export interface ConnectionProfile {
|
|
|
11
11
|
|
|
12
12
|
export interface RoutingNode { host: string; port: number | string; weight?: number; }
|
|
13
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; }
|
|
14
16
|
export interface RoutingBundle {
|
|
15
17
|
apiVersion?: string;
|
|
18
|
+
applicationId?: string;
|
|
19
|
+
databaseId?: string;
|
|
20
|
+
identityId?: string;
|
|
16
21
|
database?: string;
|
|
17
22
|
identity?: string;
|
|
18
23
|
application?: string;
|
|
@@ -26,6 +31,24 @@ export interface RoutingBundle {
|
|
|
26
31
|
writer?: WriterAssignment;
|
|
27
32
|
failover?: WriterAssignment[];
|
|
28
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;
|
|
29
52
|
}
|
|
30
53
|
export interface CredentialProviderResult { user: string; password: string; }
|
|
31
54
|
export type CredentialProvider = (context: { database: string; identity: string | null; route: string }) => Promise<CredentialProviderResult> | CredentialProviderResult;
|
|
@@ -67,8 +90,8 @@ export function validateRoutingEvent(event: unknown): RoutingEvent;
|
|
|
67
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>;
|
|
68
91
|
export function profilesFromBundle(bundle: RoutingBundle): { primary: ConnectionProfile; balanced?: ConnectionProfile };
|
|
69
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>;
|
|
70
94
|
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
95
|
export function classifyQuery(sql: unknown): 'primary' | 'balanced';
|
|
73
96
|
export function routeFor(sql: unknown, requested?: 'auto' | 'primary' | 'balanced'): 'primary' | 'balanced';
|
|
74
97
|
export function validateProfile(profile: ConnectionProfile, name?: string): ConnectionProfile;
|
|
@@ -85,6 +108,8 @@ export function createAdminSql(options: { query: QueryFunction }): { transaction
|
|
|
85
108
|
export function createMigrationRunner(options: { query: QueryFunction; migrations?: Array<{ version: number; name: string; statements: string[] }> }): { status(): Promise<{ applied: unknown[] }>; migrate(): Promise<unknown> };
|
|
86
109
|
export function selectRouteNodes(options: { bundle: RoutingBundle; route?: 'primary' | 'balanced'; now?: number }): RoutingNode[];
|
|
87
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>;
|
|
88
113
|
export function writerAssignment(bundle: RoutingBundle): WriterAssignment;
|
|
89
114
|
export function failoverNodes(bundle: RoutingBundle): WriterAssignment[];
|
|
90
115
|
export function compareBundleVersions(left: number | string | undefined, right: number | string | undefined): number;
|
package/src/index.mjs
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
export { createDb } from './client/
|
|
1
|
+
export { createDb } from './client/managed.mjs';
|
|
2
2
|
export { validateTokenContext } from './client/authorization-context.mjs';
|
|
3
3
|
export { createDbFromBundle, profilesFromBundle } from './client/from-bundle.mjs';
|
|
4
|
-
export { createDbFromEnvironment } from './client/environment.mjs';
|
|
5
4
|
export { classifyQuery, routeFor } from './routing.mjs';
|
|
6
5
|
export { validateProfile, redactedProfile } from './config.mjs';
|
|
7
6
|
export { SqlClientError, ClusterUnavailableError, ServerUnavailableError, classifyError, asSqlError } from './errors.mjs';
|
|
@@ -10,6 +9,7 @@ export { createAdminSql } from './admin/sql.mjs';
|
|
|
10
9
|
export { createMigrationRunner } from './admin/migrations.mjs';
|
|
11
10
|
export { selectRouteNodes } from './routing/node-set.mjs';
|
|
12
11
|
export { createRoutingStream } from './routing/stream-client.mjs';
|
|
12
|
+
export { fetchRoutingBundle, DEFAULT_BUNDLE_PATH } from './routing/bundle-fetcher.mjs';
|
|
13
13
|
export { validateRoutingEvent } from './routing/event-contract.mjs';
|
|
14
14
|
export { writerAssignment, failoverNodes } from './routing/assignment.mjs';
|
|
15
15
|
export { compareBundleVersions } from './routing/bundle-version.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,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
|
-
}
|