@eliware/elera-lib 0.1.10 → 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 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 Elera, HAProxy,
5
- backup, or GitOps policy. It is a v0.1.10 alternative to `@eliware/mysql`; the
6
- existing package is intentionally unchanged. The current package version is
7
- 0.1.10.
4
+ primary/balanced MySQL or MariaDB routing without embedding HAProxy, backup, or
5
+ GitOps policy.
8
6
 
9
- `primary` is the preferred connection path. `balanced` is an optional alternate
10
- path. Both may accept writes; automatic routing sends only conservative,
11
- single-statement read queries to `balanced`. Transactions always use `primary`.
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 { createDbFromEnvironment } from '@eliware/elera-lib';
15
- const db = await createDbFromEnvironment();
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
- Environment variables: `MYSQL_PRIMARY_HOST` (or `MYSQL_HOST`),
21
- `MYSQL_PRIMARY_PORT` (or `MYSQL_PORT`), optional `MYSQL_BALANCED_HOST`,
22
- optional `MYSQL_BALANCED_PORT`, `MYSQL_USER`, `MYSQL_PASSWORD`, and
23
- `MYSQL_DATABASE`. `MYSQL_SOCKET` optionally selects a Unix-domain socket for
24
- the primary connection, which is useful for local socket-authenticated MariaDB
25
- accounts. Pool settings may be supplied with `MYSQL_CONNECT_TIMEOUT`,
26
- `MYSQL_ACQUIRE_TIMEOUT`, `MYSQL_CONNECTION_LIMIT`, `MYSQL_QUEUE_LIMIT`, and
27
- `MYSQL_SSL`. Configure primary and balanced routes explicitly; applications
28
- should not rely on ambiguous single-endpoint aliases.
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 environment/bundle factories, query
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,57 @@
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
+
41
+ ## 0.1.11 — Public runtime declarations
42
+
43
+ ### Changed
44
+
45
+ - Publishes declarations for the existing `profilesFromBundle`,
46
+ `createDbFromBundle`, and `validateTokenContext` exports.
47
+ - Keeps runtime behavior unchanged while aligning declarations with the
48
+ supported JavaScript entry point.
49
+
50
+ ### Validation
51
+
52
+ - Typecheck, tests, lint, syntax, contract, and package checks pass.
53
+ - Maintains 100% statements, branches, functions, and lines coverage.
54
+
3
55
  ## 0.1.10 — Token-bound routing context
4
56
 
5
57
  ### Added
@@ -292,10 +344,9 @@ GitOps, backup, or CLI policy.
292
344
  policy.
293
345
  - Requires applications to provide their own credential and routing adapters.
294
346
 
295
- ### Compatibility and validation
347
+ ### Validation
296
348
 
297
349
  - ESM package targeting Node.js 26 or newer.
298
350
  - TypeScript declarations are included with the package.
299
- - Existing `@eliware/mysql` is not modified or required.
300
351
  - Strict test coverage is maintained at 100% statements, branches, functions,
301
352
  and lines with zero lint warnings.
@@ -1,13 +1,13 @@
1
1
  # Elera library example
2
2
 
3
- basic-client.mjs is a minimal consumer application. It imports only the
4
- public @eliware/elera-lib package API, opens a bounded SQL client from
5
- environment configuration, performs a health check and query, and closes the
6
- client in a finally block.
3
+ `basic-client.mjs` 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 from a published-package consumer project with:
8
+ Run it with:
9
9
 
10
- MYSQL_PRIMARY_HOST=127.0.0.1 MYSQL_USER=app MYSQL_PASSWORD=secret MYSQL_DATABASE=app node examples/basic-client.mjs
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 intentionally infrastructure-neutral. It does not contain
13
- Docker, Kubernetes, Supervisor, CLI, Galera, or test-lab setup.
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 { createDbFromEnvironment } from '@eliware/elera-lib';
1
+ import { createDb } from '@eliware/elera-lib';
2
2
 
3
- // Set MYSQL_PRIMARY_HOST, MYSQL_USER, MYSQL_PASSWORD, and MYSQL_DATABASE
4
- // before running this example. The same public API works with a routing bundle.
5
- const db = await createDbFromEnvironment();
3
+ // 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.1.10",
4
- "description": "Generic MySQL and MariaDB client with resilient routing, telemetry, client-side drains, and failover",
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",
@@ -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'); 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?.(); },
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
- const factory = createClient ?? (await import('./create-db.mjs')).createDb;
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;
@@ -65,7 +88,10 @@ export type RoutingEvent = { type: 'routing.update' | 'routing.resync' | 'routin
65
88
  export function validateRoutingEvent(event: unknown): RoutingEvent;
66
89
 
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
- 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>;
91
+ export function profilesFromBundle(bundle: RoutingBundle): { primary: ConnectionProfile; balanced?: ConnectionProfile };
92
+ export function createDbFromBundle(options: { bundle: RoutingBundle; createClient?: typeof createDb; credentialProvider?: CredentialProvider; tokenContext?: { application?: string; database?: string; credentialName?: string; identity?: string; scopes?: string[] }; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; quarantineMs?: number; drainTimeoutMs?: number; now?: () => number; telemetry?: true | Telemetry }): Promise<DbClient>;
93
+ export function createDb(options?: ManagedClientOptions & { fetchImpl?: typeof fetch; fetchPath?: string; WebSocketImpl?: typeof WebSocket }): Promise<DbClient>;
94
+ export function validateTokenContext(bundle: RoutingBundle, tokenContext?: { application?: string; database?: string; credentialName?: string; identity?: string; scopes?: string[] }): RoutingBundle;
69
95
  export function classifyQuery(sql: unknown): 'primary' | 'balanced';
70
96
  export function routeFor(sql: unknown, requested?: 'auto' | 'primary' | 'balanced'): 'primary' | 'balanced';
71
97
  export function validateProfile(profile: ConnectionProfile, name?: string): ConnectionProfile;
@@ -82,6 +108,8 @@ export function createAdminSql(options: { query: QueryFunction }): { transaction
82
108
  export function createMigrationRunner(options: { query: QueryFunction; migrations?: Array<{ version: number; name: string; statements: string[] }> }): { status(): Promise<{ applied: unknown[] }>; migrate(): Promise<unknown> };
83
109
  export function selectRouteNodes(options: { bundle: RoutingBundle; route?: 'primary' | 'balanced'; now?: number }): RoutingNode[];
84
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>;
85
113
  export function writerAssignment(bundle: RoutingBundle): WriterAssignment;
86
114
  export function failoverNodes(bundle: RoutingBundle): WriterAssignment[];
87
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/create-db.mjs';
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
- }