@eliware/elera-lib 0.1.2 → 0.1.4

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
@@ -2,7 +2,7 @@
2
2
 
3
3
  The alternative SQL client for Eliware applications. It provides generic
4
4
  primary/balanced MySQL or MariaDB routing without embedding Elera, HAProxy,
5
- backup, or GitOps policy. It is a v0.1.2 alternative to `@eliware/mysql`; the
5
+ backup, or GitOps policy. It is a v0.1.4 alternative to `@eliware/mysql`; the
6
6
  existing package is intentionally unchanged.
7
7
 
8
8
  `primary` is the preferred connection path. `balanced` is an optional alternate
@@ -26,12 +26,17 @@ accounts. Pool settings may be supplied with `MYSQL_CONNECT_TIMEOUT`,
26
26
  `MYSQL_SSL`. Configure primary and balanced routes explicitly; applications
27
27
  should not rely on ambiguous single-endpoint aliases.
28
28
 
29
+ See examples/basic-client.mjs for a complete consumer example using only the
30
+ public package API. Its usage notes are in examples/README.md.
31
+
29
32
  Routing bundles passed to `createDbFromBundle` use the normalized shape
30
33
  `routes.primary` and `routes.balanced`, each containing ordered `{ host, port,
31
- weight }` nodes. A bundle also carries `database`, `identity`, optional
32
- `credentials`, and `expiresAt`; `validateBundle` rejects expired or malformed
33
- route data. The checked-in contract fixture documents the supervisor-facing
34
- wire representation separately.
34
+ weight }` nodes. A bundle may also carry an explicit `writer`, ordered
35
+ `failover`, and `readers` assignment. The bundle carries `database`,
36
+ `identity`, optional `credentials`, and `expiresAt`; `validateBundle` rejects
37
+ expired, malformed, duplicated, or conflicting route data. The checked-in
38
+ contract fixture documents the supervisor-facing wire representation
39
+ separately.
35
40
 
36
41
  The implementation accepts optional routing bundles and injected credential
37
42
  providers, maintains bounded pools per route, supports ordered writer/reader
@@ -43,6 +48,15 @@ library does not know about supervisors, Elera, HAProxy, GitOps, backups, or
43
48
  CLI commands. Applications provide those integrations through ordinary
44
49
  configuration and callbacks.
45
50
 
51
+ When a route node is drained, the client immediately stops assigning new work
52
+ to that node while existing operations continue. The drain window defaults to
53
+ 45 seconds and is capped at 45 seconds; remaining pool connections are then
54
+ force-closed. `client.drain(host)` returns `wait()` and `forceClose()`
55
+ operations, while `client.nodeStates()` exposes lifecycle and active-operation
56
+ state. Only conservative, single-statement reads are eligible for automatic
57
+ retry after a connection failure; uncertain writes are never retried
58
+ automatically.
59
+
46
60
  The public client intentionally exposes SQL operations, health, routing,
47
61
  lifecycle, and optional routing-event synchronization methods. REST and
48
62
  WebSocket transports are adapters, not supervisor or CLI policy. Underlying
package/RELEASE_NOTES.md CHANGED
@@ -1,5 +1,68 @@
1
1
  # Release notes
2
2
 
3
+ ## 0.1.4 — Explicit writer and failover routing
4
+
5
+ This release strengthens generic client-side routing for supervisor-provided
6
+ bundles. Applications still use the public library API; supervisor and CLI
7
+ policy remain outside the package.
8
+
9
+ ### Added
10
+
11
+ - Supports explicit `writer`, ordered `failover`, and `readers` assignments in
12
+ routing bundles.
13
+ - Replaces active writer and reader pools immediately when a valid routing
14
+ update arrives through WebSocket or REST resynchronization.
15
+ - Keeps application clients independent so updating one application's bundle
16
+ does not change another client's assignment.
17
+ - Selects the writer first and fails over in the supplied order when a node is
18
+ drained or explicitly unavailable.
19
+ - Completes in-flight work during drain while excluding the node from new work.
20
+ - Enforces a 45-second maximum client-side drain window.
21
+
22
+ ### Fixed and hardened
23
+
24
+ - Compares numeric and string bundle versions numerically and rejects stale
25
+ updates, including versions such as `v10` versus `v9`.
26
+ - Validates routing hosts, ports, weights, duplicate nodes, and writer/failover
27
+ overlap before pool creation.
28
+ - Preserves explicit writer, failover, and reader assignments during stream
29
+ refreshes.
30
+
31
+ ### Validation
32
+
33
+ - Adds integration-style coverage for pool replacement, per-client assignment
34
+ isolation, writer failover, in-flight drain behavior, version ordering,
35
+ WebSocket reconnect, and REST fallback.
36
+ - Maintains 100% statements, branches, functions, and lines coverage with zero
37
+ lint warnings.
38
+ - Diff validation passes.
39
+
40
+ ## 0.1.3 — Client-side routing drain lifecycle
41
+
42
+ This patch adds generic client-side handling for supervisor-published routing
43
+ drains and topology resynchronization. It does not add Supervisor, CLI, Galera,
44
+ backup, or GitOps policy to the library.
45
+
46
+ ### Added
47
+
48
+ - Tracks active operations and acquired connections per route node.
49
+ - Exposes node lifecycle state and client drain status.
50
+ - Immediately excludes draining nodes from new work and force-closes remaining
51
+ pool connections after the configurable 45-second default drain window.
52
+ - Handles recovery events for primary and balanced routes.
53
+ - Retries eligible read operations only; uncertain writes are not retried.
54
+ - Rejects stale routing events and applies REST resync bundles through the
55
+ active routing handler.
56
+ - Adds WebSocket heartbeat scheduling and cleanup during reconnect and close.
57
+
58
+ ### Validation
59
+
60
+ - Adds regression coverage for drain completion, forced cutoff, connection
61
+ accounting, safe retry behavior, stale events, REST resync, and heartbeats.
62
+ - Maintains 100% statements, branches, functions, and lines coverage with zero
63
+ lint warnings.
64
+ - Typecheck and diff validation pass.
65
+
3
66
  ## 0.1.2 — Unix-socket connection correction
4
67
 
5
68
  This patch corrects the mysql2 option used for local MariaDB Unix-domain
@@ -0,0 +1,13 @@
1
+ # Elera library example
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.
7
+
8
+ Run it from a published-package consumer project with:
9
+
10
+ MYSQL_PRIMARY_HOST=127.0.0.1 MYSQL_USER=app MYSQL_PASSWORD=secret MYSQL_DATABASE=app node examples/basic-client.mjs
11
+
12
+ The example is intentionally infrastructure-neutral. It does not contain
13
+ Docker, Kubernetes, Supervisor, CLI, Galera, or test-lab setup.
@@ -0,0 +1,14 @@
1
+ import { createDbFromEnvironment } from '@eliware/elera-lib';
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();
6
+
7
+ try {
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
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@eliware/elera-lib",
3
- "version": "0.1.2",
4
- "description": "Generic MySQL and MariaDB client with primary, balanced, and resilient routing",
3
+ "version": "0.1.4",
4
+ "description": "Generic MySQL and MariaDB client with resilient routing, client-side drains, and failover",
5
5
  "keywords": [
6
6
  "eliware",
7
7
  "elera",
@@ -35,6 +35,7 @@
35
35
  },
36
36
  "files": [
37
37
  "src",
38
+ "examples",
38
39
  "README.md",
39
40
  "RELEASE_NOTES.md",
40
41
  "LICENSE",
package/src/bundle.mjs CHANGED
@@ -1,13 +1,17 @@
1
+ import { validateRoutingNode, validateRoutingNodes } from './routing/node-validation.mjs';
1
2
  const routes = ['primary', 'balanced'];
2
3
 
3
4
  export function validateBundle(bundle) {
4
5
  if (!bundle || typeof bundle !== 'object') throw new TypeError('routing bundle is required');
5
6
  if (!bundle.expiresAt || Number.isNaN(Date.parse(bundle.expiresAt))) throw new TypeError('routing bundle expiresAt is required');
7
+ const writer = bundle.writer !== undefined ? validateRoutingNode(bundle.writer, 'routing bundle writer') : undefined;
8
+ const failover = bundle.failover !== undefined ? validateRoutingNodes(bundle.failover, 'routing bundle failover') : [];
9
+ if (writer && failover.some((node) => node.host === writer.host && node.port === writer.port)) throw new TypeError('routing bundle failover duplicates writer');
10
+ if (bundle.readers !== undefined) validateRoutingNodes(bundle.readers, 'routing bundle readers');
6
11
  for (const route of routes) {
7
12
  if (bundle.routes?.[route] !== undefined && !Array.isArray(bundle.routes[route])) throw new TypeError(`bundle.routes.${route} must be an array`);
8
13
  for (const node of bundle.routes?.[route] ?? []) {
9
- if (!node.host || !Number.isInteger(Number(node.port)) || Number(node.port) < 1 || Number(node.port) > 65535) throw new TypeError(`invalid ${route} bundle node`);
10
- if (node.weight !== undefined && (!Number.isFinite(Number(node.weight)) || Number(node.weight) < 0)) throw new TypeError(`invalid ${route} bundle weight`);
14
+ validateRoutingNode(node, `routing bundle ${route} node`);
11
15
  }
12
16
  }
13
17
  return bundle;
@@ -0,0 +1,7 @@
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
+ }
@@ -6,8 +6,12 @@ import { resolveCredentials, credentialContext } from '../credential-provider.mj
6
6
  import { validateBundle, bundleExpired, bundleNeedsRefresh } from '../bundle.mjs';
7
7
  import { createRouteFactory } from './route-factory.mjs';
8
8
  import { classifyQuery, routeFor } from '../routing.mjs';
9
+ import { compareBundleVersions } from '../routing/bundle-version.mjs';
10
+ import { clientDrainTimeout } from '../lifecycle/drain-policy.mjs';
9
11
 
10
- export async function createDb({ primary, balanced, bundle, credentialProvider, mysqlLib = mysql, log = defaultLog, routing = 'auto', identity, quarantineMs = 5000, now = () => Date.now() } = {}) {
12
+ const olderVersion = (candidate, current) => compareBundleVersions(candidate, current) < 0;
13
+
14
+ export async function createDb({ primary, balanced, bundle, credentialProvider, mysqlLib = mysql, log = defaultLog, routing = 'auto', identity, quarantineMs = 5000, drainTimeoutMs = 45000, now = () => Date.now() } = {}) {
11
15
  if (!primary || typeof primary !== 'object') throw new TypeError('primary connection profile is required');
12
16
  const credentials = await resolveCredentials(credentialProvider, credentialContext(primary, { identity }));
13
17
  let primaryConfig = validateProfile({ ...primary, ...credentials }, 'primary');
@@ -23,12 +27,14 @@ export async function createDb({ primary, balanced, bundle, credentialProvider,
23
27
  let balancedPool = balancedConfig || activeBundle?.routes?.balanced ? makeRoute('balanced', balancedConfig ?? primaryConfig) : null;
24
28
  const choose = (sql, options = {}) => options.connection ?? (routeFor(sql, options.route ?? routing) === 'balanced' && balancedPool ? balancedPool : primaryPool);
25
29
  const client = {
26
- async query(sql, values, options) { const selected = choose(sql, options); try { return await selected.query(sql, values); } catch (error) { if (error.retryable && routeFor(sql, options?.route ?? routing) === 'balanced') return balancedPool.query(sql, values); throw error; } },
30
+ async query(sql, values, options) { 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') return balancedPool.query(sql, values); throw error; } },
27
31
  async execute(sql, values, options) { return choose(sql, options).execute(sql, values); },
28
32
  async transaction(callback) { 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(); } },
29
33
  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 }; },
30
- async refresh(nextBundle) { const candidate = validateBundle(nextBundle); if (bundleExpired(candidate, now())) throw new Error('routing bundle is expired'); const previous = [primaryPool, balancedPool]; const credentials = candidate.credentials ?? { username: primaryConfig.user, password: primaryConfig.password }; primaryConfig = validateProfile({ ...primaryConfig, host: candidate.routes.primary[0]?.host, port: candidate.routes.primary[0]?.port, user: credentials.username, password: credentials.password, database: candidate.database }, 'primary'); balancedConfig = candidate.routes.balanced?.[0] ? validateProfile({ ...primaryConfig, host: candidate.routes.balanced[0].host, port: candidate.routes.balanced[0].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()) }; },
31
- async attachRoutingStream(stream) { if (!stream?.connect) throw new TypeError('routing stream is required'); stream.setOnUpdate?.(async (event) => { if (event.type === 'routing.update' && event.routes?.primary?.length) await client.refresh({ ...activeBundle, database: event.database ?? activeBundle?.database ?? primaryConfig.database, credentials: event.credentials ?? activeBundle?.credentials, routes: event.routes, bundleVersion: event.bundleVersion ?? activeBundle?.bundleVersion, expiresAt: activeBundle?.expiresAt ?? new Date(now() + 60000).toISOString() }); for (const host of event.type === 'routing.drain' ? [event.node] : event.type === 'routing.recovery' ? [event.node] : []) client.setNodeAvailability('primary', host, event.type === 'routing.recovery'); }); await stream.connect(); return () => stream.close?.(); },
34
+ 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()) }; },
35
+ async attachRoutingStream(stream) { if (!stream?.connect) throw new TypeError('routing stream is required'); 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.recovery') for (const pool of [primaryPool, balancedPool].filter(Boolean)) (event.type === 'routing.drain' ? pool.drain : pool.recover)(event.node, drainTimeoutMs); }); await stream.connect(); return () => stream.close?.(); },
36
+ 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))) }; },
37
+ 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 }))); },
32
38
  setNodeAvailability(route, host, available) { const pool = route === 'balanced' ? balancedPool : primaryPool; pool?.setAvailability(host, available); },
33
39
  bundle: () => activeBundle,
34
40
  async close() { await Promise.all([primaryPool.close(), balancedPool?.close()]); },
@@ -1,10 +1,6 @@
1
1
  import { bundleExpired } from '../bundle.mjs';
2
2
  import { createNodePool, createRoutePool } from '../pools.mjs';
3
-
4
- const bundleProfiles = (bundle, route, base) => {
5
- const routes = bundle.routes?.[route] ?? [];
6
- return routes.map((node) => ({ ...base, host: node.host, port: node.port, weight: node.weight }));
7
- };
3
+ import { bundleProfiles } from './bundle-profiles.mjs';
8
4
 
9
5
  const routeProfiles = (bundle, route, fallback, now) => {
10
6
  if (!bundle) return [fallback];
@@ -14,5 +10,5 @@ const routeProfiles = (bundle, route, fallback, now) => {
14
10
  };
15
11
 
16
12
  export function createRouteFactory({ bundle, now, mysqlLib, log, quarantineMs }) {
17
- return (route, fallback) => createRoutePool(routeProfiles(bundle, route, fallback, now).map((profile) => createNodePool({ profile, mysqlLib, log, now, quarantineMs })));
13
+ return (route, fallback) => createRoutePool(routeProfiles(bundle, route, fallback, now).map((profile) => createNodePool({ profile, mysqlLib, log, now, quarantineMs })), { preferred: route === 'primary' });
18
14
  }
package/src/index.d.ts CHANGED
@@ -10,6 +10,7 @@ export interface ConnectionProfile {
10
10
  }
11
11
 
12
12
  export interface RoutingNode { host: string; port: number | string; weight?: number; }
13
+ export interface WriterAssignment { host: string; port: number | string; }
13
14
  export interface RoutingBundle {
14
15
  apiVersion?: string;
15
16
  database?: string;
@@ -19,6 +20,9 @@ export interface RoutingBundle {
19
20
  expiresAt: string;
20
21
  refreshAfter?: string;
21
22
  routes: { primary?: RoutingNode[]; balanced?: RoutingNode[] };
23
+ writer?: WriterAssignment;
24
+ failover?: WriterAssignment[];
25
+ readers?: WriterAssignment[];
22
26
  }
23
27
  export interface CredentialProviderResult { user: string; password: string; }
24
28
  export type CredentialProvider = (context: { database: string; identity: string | null; route: string }) => Promise<CredentialProviderResult> | CredentialProviderResult;
@@ -36,6 +40,8 @@ export interface DbClient {
36
40
  classify(sql: string): 'primary' | 'balanced';
37
41
  attachRoutingStream(stream: RoutingStream): Promise<() => void>;
38
42
  setNodeAvailability(route: 'primary' | 'balanced', host: string, available: boolean): void;
43
+ drain(host: string, timeoutMs?: number): { host: string; timeoutMs: number; wait(): Promise<unknown[]>; forceClose(): Promise<unknown[]> };
44
+ nodeStates(): Array<{ host: string; port: number; route: 'primary' | 'balanced'; state: 'ready' | 'draining' | 'unavailable' | 'recovering'; active: number; available: boolean }>;
39
45
  config: { primary: ConnectionProfile; balanced?: ConnectionProfile };
40
46
  }
41
47
 
@@ -43,10 +49,10 @@ export interface RoutingStream {
43
49
  connect(): Promise<void>;
44
50
  setOnUpdate(handler: (event: unknown) => void | Promise<void>): void;
45
51
  close(): void;
46
- state(): { connected: boolean; mode: 'websocket' | 'rest' | 'disconnected'; expectedVersion: number };
52
+ state(): { connected: boolean; mode: 'websocket' | 'rest' | 'disconnected'; expectedVersion: number | string };
47
53
  }
48
54
 
49
- export function createDb(options: { primary: ConnectionProfile; balanced?: Partial<ConnectionProfile>; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; quarantineMs?: number; now?: () => number }): Promise<DbClient>;
55
+ export function createDb(options: { primary: ConnectionProfile; balanced?: Partial<ConnectionProfile>; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; quarantineMs?: number; drainTimeoutMs?: number; now?: () => number }): Promise<DbClient>;
50
56
  export function createDbFromEnvironment(options?: { env?: Record<string, string | undefined>; mysqlLib?: unknown; log?: unknown; routing?: 'auto' | 'primary' | 'balanced'; bundle?: RoutingBundle; credentialProvider?: CredentialProvider; identity?: string }): Promise<DbClient>;
51
57
  export function classifyQuery(sql: unknown): 'primary' | 'balanced';
52
58
  export function routeFor(sql: unknown, requested?: 'auto' | 'primary' | 'balanced'): 'primary' | 'balanced';
@@ -61,7 +67,12 @@ export function bundleNeedsRefresh(bundle: RoutingBundle, now?: number): boolean
61
67
  export function createAdminSql(options: { query: QueryFunction }): { transaction<T>(work: (context: { query: QueryFunction }) => Promise<T>): Promise<T>; migration(statements?: string[]): Promise<unknown> };
62
68
  export function createMigrationRunner(options: { query: QueryFunction; migrations?: Array<{ version: number; name: string; statements: string[] }> }): { status(): Promise<{ applied: unknown[] }>; migrate(): Promise<unknown> };
63
69
  export function selectRouteNodes(options: { bundle: RoutingBundle; route?: 'primary' | 'balanced'; now?: number }): RoutingNode[];
64
- export function createRoutingStream(options: { endpoint: string; token?: string; application?: string; fetchBundle: (application: string) => Promise<RoutingBundle>; onUpdate?: (event: unknown) => void; onError?: (error: unknown) => void; reconnectMs?: number; maxReconnectMs?: number }): RoutingStream;
70
+ export function createRoutingStream(options: { endpoint: string; token?: string; application?: string; fetchBundle: (application: string) => Promise<RoutingBundle>; onUpdate?: (event: unknown) => void; onError?: (error: unknown) => void; reconnectMs?: number; maxReconnectMs?: number; heartbeatMs?: number }): RoutingStream;
71
+ export function writerAssignment(bundle: RoutingBundle): WriterAssignment;
72
+ export function failoverNodes(bundle: RoutingBundle): WriterAssignment[];
73
+ export function compareBundleVersions(left: number | string | undefined, right: number | string | undefined): number;
74
+ export const CLIENT_DRAIN_TIMEOUT_MS: 45000;
75
+ export function clientDrainTimeout(timeoutMs?: number): number;
65
76
  export function createQuiesceController(options?: { close?: () => Promise<void>; onChange?: (state: string) => void }): { state(): { state: string; active: number }; enter(): () => void; begin(): Promise<void>; end(): Promise<void>; close(): Promise<void> };
66
77
  export function createSqlVerifier(options: { query: QueryFunction }): { connectivity(): Promise<{ verified: boolean }>; schema(database: string): Promise<{ database: string; verified: boolean }>; account(user: string, host?: string): Promise<{ user: string; host: string; verified: boolean; grants: string[] }>; all(options?: { database?: string; user?: string; host?: string }): Promise<unknown> };
67
78
  export function createMaterializer(options?: Record<string, unknown>): unknown;
package/src/index.mjs CHANGED
@@ -9,6 +9,9 @@ export { createAdminSql } from './admin/sql.mjs';
9
9
  export { createMigrationRunner } from './admin/migrations.mjs';
10
10
  export { selectRouteNodes } from './routing/node-set.mjs';
11
11
  export { createRoutingStream } from './routing/stream-client.mjs';
12
+ export { writerAssignment, failoverNodes } from './routing/assignment.mjs';
13
+ export { compareBundleVersions } from './routing/bundle-version.mjs';
14
+ export { CLIENT_DRAIN_TIMEOUT_MS, clientDrainTimeout } from './lifecycle/drain-policy.mjs';
12
15
  export { createQuiesceController } from './lifecycle/quiesce.mjs';
13
16
  export { createSqlVerifier } from './verification/sql.mjs';
14
17
  export { createMaterializer } from './lifecycle/materializer.mjs';
@@ -0,0 +1,7 @@
1
+ export const CLIENT_DRAIN_TIMEOUT_MS = 45000;
2
+
3
+ export function clientDrainTimeout(timeoutMs = CLIENT_DRAIN_TIMEOUT_MS) {
4
+ const requested = Number(timeoutMs);
5
+ if (!Number.isFinite(requested) || requested < 0) throw new TypeError('drain timeout must be a non-negative number');
6
+ return Math.min(requested, CLIENT_DRAIN_TIMEOUT_MS);
7
+ }
@@ -6,11 +6,18 @@ export function createNodePool({ profile, mysqlLib, log, now = () => Date.now(),
6
6
  const pool = mysqlLib.createPool({ host: profile.host, port: profile.port, user: profile.user, password: profile.password, database: profile.database, waitForConnections: true, ...driverOptions });
7
7
  const sessionStatements = profile.options?.sessionStatements ?? [];
8
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;
10
- return { host: profile.host, port: profile.port, weight: Number(profile.weight ?? 100), get available() { return !forcedUnavailable && now() >= unavailableUntil; }, set available(value) { forcedUnavailable = !value; if (value) unavailableUntil = 0; else unavailableUntil = now() + quarantineMs; }, get failures() { return failures; },
11
- async query(sql, values) { try { const result = sessionStatements.length ? await withConnection((connection, statement, params) => connection.query(statement, params), sql, values) : await 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); } },
12
- async execute(sql, values) { try { const result = sessionStatements.length ? await withConnection((connection, statement, params) => connection.execute(statement, params), sql, values) : await pool.execute(sql, values); failures = 0; return result; } catch (error) { if (connectionFailure(error)) { failures += 1; unavailableUntil = now() + quarantineMs; } throw asSqlError(error); } },
13
- async getConnection() { try { return await pool.getConnection(); } catch (error) { if (connectionFailure(error)) unavailableUntil = now() + quarantineMs; throw asSqlError(error); } },
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); } },
14
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); } },
15
- async close() { await pool.end(); } };
22
+ async close() { clearTimeout(drainTimer); lifecycle = 'unavailable'; await pool.end(); } };
16
23
  }
@@ -1,9 +1,10 @@
1
- export function createRoutePool(nodes) {
1
+ export function createRoutePool(nodes, { preferred = false } = {}) {
2
2
  let cursor = 0;
3
3
  const candidates = () => nodes.filter((node) => node.available);
4
4
  const choose = () => {
5
5
  const available = candidates();
6
6
  if (!available.length) throw new Error('no eligible SQL nodes available');
7
+ if (preferred) return available[0];
7
8
  const total = available.reduce((sum, node) => sum + Math.max(0, node.weight), 0);
8
9
  if (!total) return available[cursor++ % available.length];
9
10
  let target = cursor++ % total;
@@ -21,6 +22,11 @@ export function createRoutePool(nodes) {
21
22
  const setAvailability = (host, available) => {
22
23
  for (const node of nodes) if (node.host === host) node.available = available;
23
24
  };
25
+ const lifecycle = (host, state) => nodes.filter((node) => node.host === host).map((node) => state === 'draining' ? node.drain() : node.recover());
26
+ const drain = (host, timeoutMs) => { for (const node of nodes.filter((value) => value.host === host)) node.drain?.(timeoutMs); return lifecycle(host, 'draining'); };
27
+ const recover = (host) => lifecycle(host, 'recovering');
28
+ const waitForIdle = (timeoutMs) => Promise.all(nodes.map((node) => node.waitForIdle?.(timeoutMs) ?? true));
29
+ const forceClose = (host) => Promise.all(nodes.filter((node) => !host || node.host === host).map((node) => node.forceClose?.() ?? node.close()));
24
30
  const query = (sql, values) => choose().query(sql, values);
25
31
  const execute = (sql, values) => choose().execute(sql, values);
26
32
  const health = async () => {
@@ -35,5 +41,5 @@ export function createRoutePool(nodes) {
35
41
  return results;
36
42
  };
37
43
  const close = async () => Promise.all(nodes.map((node) => node.close()));
38
- return { nodes, choose, setAvailability, query, execute, health, close };
44
+ return { nodes, choose, setAvailability, drain, recover, waitForIdle, forceClose, query, execute, health, close };
39
45
  }
@@ -0,0 +1,9 @@
1
+ export function writerAssignment(bundle) {
2
+ if (!bundle?.writer?.host) throw new TypeError('routing bundle writer is required');
3
+ return { host: bundle.writer.host, port: Number(bundle.writer.port ?? 3306) };
4
+ }
5
+
6
+ export function failoverNodes(bundle) {
7
+ if (!Array.isArray(bundle?.failover)) throw new TypeError('routing bundle failover is required');
8
+ return bundle.failover.map(({ host, port = 3306 }) => ({ host, port: Number(port) }));
9
+ }
@@ -0,0 +1,11 @@
1
+ export function compareBundleVersions(left, right) {
2
+ if (left === undefined || right === undefined) return 0;
3
+ const a = String(left).match(/\d+/g)?.map(Number);
4
+ const b = String(right).match(/\d+/g)?.map(Number);
5
+ if (!a || !b) return String(left).localeCompare(String(right));
6
+ for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
7
+ const difference = (a[index] ?? 0) - (b[index] ?? 0);
8
+ if (difference) return Math.sign(difference);
9
+ }
10
+ return 0;
11
+ }
@@ -0,0 +1,14 @@
1
+ export function validateRoutingNode(node, name = 'routing node') {
2
+ if (!node || typeof node !== 'object' || typeof node.host !== 'string' || node.host.trim() === '') throw new TypeError(`${name} host is required`);
3
+ const port = Number(node.port ?? 3306);
4
+ if (!Number.isInteger(port) || port < 1 || port > 65535) throw new TypeError(`${name} port is invalid`);
5
+ if (node.weight !== undefined && (!Number.isFinite(Number(node.weight)) || Number(node.weight) < 0)) throw new TypeError(`${name} weight is invalid`);
6
+ return { ...node, host: node.host.trim(), port };
7
+ }
8
+
9
+ export function validateRoutingNodes(nodes, name) {
10
+ if (!Array.isArray(nodes)) throw new TypeError(`${name} must be an array`);
11
+ const validated = nodes.map((node, index) => validateRoutingNode(node, `${name}[${index}]`));
12
+ if (new Set(validated.map(({ host, port }) => `${host}:${port}`)).size !== validated.length) throw new TypeError(`${name} contains duplicate nodes`);
13
+ return validated;
14
+ }
@@ -1,27 +1,31 @@
1
1
  import { log as defaultLog } from '@eliware/common';
2
+ import { compareBundleVersions } from './bundle-version.mjs';
2
3
 
3
- export function createRoutingStream({ endpoint, token, application = 'default', fetchBundle, WebSocketImpl = globalThis.WebSocket, onUpdate, onError, reconnectMs = 1000, maxReconnectMs = 30000, now = () => Date.now() } = {}) {
4
+ export function createRoutingStream({ endpoint, token, application = 'default', fetchBundle, WebSocketImpl = globalThis.WebSocket, onUpdate, onError, reconnectMs = 1000, maxReconnectMs = 30000, heartbeatMs = 45000, now = () => Date.now() } = {}) {
4
5
  if (!endpoint || typeof fetchBundle !== 'function') throw new TypeError('endpoint and fetchBundle are required');
5
- let socket; let closed = false; let timer; let expectedVersion = 0; let delay = reconnectMs; let updateHandler = onUpdate; let mode = 'disconnected';
6
+ let socket; let closed = false; let timer; let heartbeat; let expectedVersion = 0; let delay = reconnectMs; let updateHandler = onUpdate; let mode = 'disconnected';
6
7
  const log = arguments[0]?.log ?? defaultLog;
7
8
  const streamUrl = () => `${endpoint.replace(/^http/i, 'ws').replace(/\/$/, '')}/api/v1/routing/stream?application=${encodeURIComponent(application)}&token=${encodeURIComponent(token ?? '')}`;
8
- async function fallback() { try { const bundle = await fetchBundle(application); if (closed) return; mode = 'rest'; onUpdate?.({ type: 'routing.resync', version: expectedVersion, bundle, receivedAt: now() }); } catch (error) { if (closed) return; mode = 'disconnected'; onError?.(error); log.warn?.('Routing REST fallback failed', { error }); } }
9
+ async function fallback() { try { const bundle = await fetchBundle(application); 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 }); } }
9
10
  function schedule() { if (closed || timer) return; timer = setTimeout(() => { timer = undefined; void connect(); }, delay); delay = Math.min(maxReconnectMs, delay * 2); }
10
11
  async function connect() {
11
12
  if (closed || typeof WebSocketImpl !== 'function') { await fallback(); schedule(); return; }
12
13
  try {
13
14
  socket = new WebSocketImpl(streamUrl());
14
- socket.onopen = () => { mode = 'websocket'; delay = reconnectMs; };
15
+ socket.onopen = () => { mode = 'websocket'; delay = reconnectMs; heartbeat = setInterval(() => socket?.send?.(JSON.stringify({ type: 'heartbeat', sentAt: now() })), heartbeatMs); };
15
16
  socket.onmessage = async ({ data }) => {
16
17
  try {
17
- const event = JSON.parse(data); const version = Number(event.version ?? 0);
18
- if (expectedVersion && version > expectedVersion + 1) await fallback();
19
- expectedVersion = Math.max(expectedVersion, version); updateHandler?.(event);
18
+ const event = JSON.parse(data); const version = event.version;
19
+ if (version !== undefined && expectedVersion !== 0 && compareBundleVersions(version, expectedVersion) <= 0) return;
20
+ const numericVersion = Number(version); const numericExpected = Number(expectedVersion);
21
+ if (Number.isInteger(numericVersion) && Number.isInteger(numericExpected) && numericExpected > 0 && numericVersion > numericExpected + 1) await fallback();
22
+ if (version !== undefined && (expectedVersion === 0 || compareBundleVersions(version, expectedVersion) > 0)) expectedVersion = version;
23
+ updateHandler?.(event);
20
24
  } catch (error) { onError?.(error); }
21
25
  };
22
26
  socket.onerror = (error) => { onError?.(error); };
23
- socket.onclose = () => { socket = undefined; mode = 'disconnected'; if (!closed) { void fallback(); schedule(); } };
27
+ socket.onclose = () => { clearInterval(heartbeat); heartbeat = undefined; socket = undefined; mode = 'disconnected'; if (!closed) { void fallback(); schedule(); } };
24
28
  } catch (error) { mode = 'disconnected'; onError?.(error); await fallback(); schedule(); }
25
29
  }
26
- return { connect, setOnUpdate: (handler) => { updateHandler = handler; }, close: () => { closed = true; mode = 'disconnected'; clearTimeout(timer); socket?.close?.(); }, state: () => ({ connected: socket?.readyState === 1, mode, expectedVersion }) };
30
+ return { connect, setOnUpdate: (handler) => { updateHandler = handler; }, close: () => { closed = true; mode = 'disconnected'; clearTimeout(timer); clearInterval(heartbeat); socket?.close?.(); }, state: () => ({ connected: socket?.readyState === 1, mode, expectedVersion }) };
27
31
  }